Repository: fosrl/pangolin Branch: main Commit: 484326853715 Files: 1253 Total size: 8.9 MB Directory structure: gitextract_nwjdm6gt/ ├── .dockerignore ├── .editorconfig ├── .eslintrc.json ├── .github/ │ ├── DISCUSSION_TEMPLATE/ │ │ └── feature-requests.yml │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── 1.bug_report.yml │ │ └── config.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── dependabot.yml │ └── workflows/ │ ├── cicd.yml │ ├── linting.yml │ ├── mirror.yaml │ ├── restart-runners.yml │ ├── saas.yml │ ├── stale-bot.yml │ └── test.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── CONTRIBUTING.md ├── Dockerfile ├── Dockerfile.dev ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── bruno/ │ ├── API Keys/ │ │ ├── Create API Key.bru │ │ ├── Delete API Key.bru │ │ ├── List API Key Actions.bru │ │ ├── List Org API Keys.bru │ │ ├── List Root API Keys.bru │ │ ├── Set API Key Actions.bru │ │ ├── Set API Key Orgs.bru │ │ └── folder.bru │ ├── Auth/ │ │ ├── 2fa-disable.bru │ │ ├── 2fa-enable.bru │ │ ├── 2fa-request.bru │ │ ├── change-password.bru │ │ ├── login.bru │ │ ├── logout.bru │ │ ├── reset-password-request.bru │ │ ├── reset-password.bru │ │ ├── signup.bru │ │ ├── verify-email-request.bru │ │ ├── verify-email.bru │ │ └── verify-user.bru │ ├── Clients/ │ │ ├── createClient.bru │ │ └── pickClientDefaults.bru │ ├── IDP/ │ │ ├── Create OIDC Provider.bru │ │ ├── Generate OIDC URL.bru │ │ └── folder.bru │ ├── Internal/ │ │ ├── Traefik Config.bru │ │ └── folder.bru │ ├── Newt/ │ │ ├── Create Newt.bru │ │ └── Get Token.bru │ ├── Olm/ │ │ ├── createOlm.bru │ │ └── folder.bru │ ├── Orgs/ │ │ ├── Check Id.bru │ │ └── listOrgs.bru │ ├── Remote Exit Node/ │ │ └── createRemoteExitNode.bru │ ├── Resources/ │ │ ├── listResourcesByOrg.bru │ │ └── listResourcesBySite.bru │ ├── Sites/ │ │ ├── Get Site.bru │ │ └── listSites.bru │ ├── Targets/ │ │ └── listTargets.bru │ ├── Test.bru │ ├── Traefik/ │ │ └── traefik-config.bru │ ├── Users/ │ │ ├── adminListUsers.bru │ │ ├── adminRemoveUser.bru │ │ └── getUser.bru │ └── bruno.json ├── cli/ │ ├── commands/ │ │ ├── clearExitNodes.ts │ │ ├── clearLicenseKeys.ts │ │ ├── deleteClient.ts │ │ ├── generateOrgCaKeys.ts │ │ ├── resetUserSecurityKeys.ts │ │ ├── rotateServerSecret.ts │ │ └── setAdminCredentials.ts │ ├── index.ts │ └── wrapper.sh ├── components.json ├── config/ │ ├── .gitkeep │ ├── config.example.yml │ ├── db/ │ │ └── .gitkeep │ ├── logs/ │ │ └── .gitkeep │ └── traefik/ │ ├── dynamic_config.yml │ └── traefik_config.yml ├── crowdin.yml ├── docker-compose.drizzle.yml ├── docker-compose.example.yml ├── docker-compose.pgr.yml ├── docker-compose.yml ├── drizzle.pg.config.ts ├── drizzle.sqlite.config.ts ├── esbuild.mjs ├── eslint.config.js ├── install/ │ ├── Makefile │ ├── config/ │ │ ├── config.yml │ │ ├── crowdsec/ │ │ │ ├── acquis.d/ │ │ │ │ ├── appsec.yaml │ │ │ │ └── traefik.yaml │ │ │ ├── docker-compose.yml │ │ │ ├── dynamic_config.yml │ │ │ ├── profiles.yaml │ │ │ └── traefik_config.yml │ │ ├── docker-compose.yml │ │ └── traefik/ │ │ ├── dynamic_config.yml │ │ └── traefik_config.yml │ ├── config.go │ ├── containers.go │ ├── crowdsec.go │ ├── get-installer.sh │ ├── go.mod │ ├── go.sum │ ├── input.go │ ├── input.txt │ ├── main.go │ └── theme.go ├── messages/ │ ├── bg-BG.json │ ├── cs-CZ.json │ ├── de-DE.json │ ├── en-US.json │ ├── es-ES.json │ ├── fr-FR.json │ ├── it-IT.json │ ├── ko-KR.json │ ├── nb-NO.json │ ├── nl-NL.json │ ├── pl-PL.json │ ├── pt-PT.json │ ├── ru-RU.json │ ├── tr-TR.json │ ├── zh-CN.json │ └── zh-TW.json ├── next.config.ts ├── package.json ├── postcss.config.mjs ├── server/ │ ├── apiServer.ts │ ├── auth/ │ │ ├── actions.ts │ │ ├── canUserAccessResource.ts │ │ ├── canUserAccessSiteResource.ts │ │ ├── checkValidInvite.ts │ │ ├── password.ts │ │ ├── passwordSchema.ts │ │ ├── resourceOtp.ts │ │ ├── sendEmailVerificationCode.ts │ │ ├── sessions/ │ │ │ ├── app.ts │ │ │ ├── newt.ts │ │ │ ├── olm.ts │ │ │ ├── resource.ts │ │ │ └── verifySession.ts │ │ ├── totp.ts │ │ ├── unauthorizedResponse.ts │ │ └── verifyResourceAccessToken.ts │ ├── cleanup.ts │ ├── db/ │ │ ├── README.md │ │ ├── asns.ts │ │ ├── countries.ts │ │ ├── ios_models.json │ │ ├── mac_models.json │ │ ├── maxmind.ts │ │ ├── maxmindAsn.ts │ │ ├── migrate.ts │ │ ├── names.json │ │ ├── names.ts │ │ ├── pg/ │ │ │ ├── driver.ts │ │ │ ├── index.ts │ │ │ ├── logsDriver.ts │ │ │ ├── migrate.ts │ │ │ ├── safeRead.ts │ │ │ └── schema/ │ │ │ ├── privateSchema.ts │ │ │ └── schema.ts │ │ ├── queries/ │ │ │ └── verifySessionQueries.ts │ │ └── sqlite/ │ │ ├── driver.ts │ │ ├── index.ts │ │ ├── logsDriver.ts │ │ ├── migrate.ts │ │ ├── safeRead.ts │ │ └── schema/ │ │ ├── privateSchema.ts │ │ └── schema.ts │ ├── emails/ │ │ ├── index.ts │ │ ├── sendEmail.ts │ │ └── templates/ │ │ ├── EnterpriseEditionKeyGenerated.tsx │ │ ├── NotifyResetPassword.tsx │ │ ├── NotifyUsageLimitApproaching.tsx │ │ ├── NotifyUsageLimitReached.tsx │ │ ├── ResetPasswordCode.tsx │ │ ├── ResourceOTPCode.tsx │ │ ├── SendInviteLink.tsx │ │ ├── SupportEmail.tsx │ │ ├── TwoFactorAuthNotification.tsx │ │ ├── VerifyEmailCode.tsx │ │ ├── WelcomeQuickStart.tsx │ │ ├── components/ │ │ │ ├── ButtonLink.tsx │ │ │ ├── CopyCodeBox.tsx │ │ │ └── Email.tsx │ │ └── lib/ │ │ └── theme.ts │ ├── extendZod.ts │ ├── index.ts │ ├── integrationApiServer.ts │ ├── internalServer.ts │ ├── lib/ │ │ ├── asn.ts │ │ ├── billing/ │ │ │ ├── createCustomer.ts │ │ │ ├── features.ts │ │ │ ├── getLineItems.ts │ │ │ ├── getOrgTierData.ts │ │ │ ├── index.ts │ │ │ ├── licenses.ts │ │ │ ├── limitSet.ts │ │ │ ├── limitsService.ts │ │ │ ├── tierMatrix.ts │ │ │ └── usageService.ts │ │ ├── blueprints/ │ │ │ ├── MaintenanceSchema.ts │ │ │ ├── applyBlueprint.ts │ │ │ ├── applyNewtDockerBlueprint.ts │ │ │ ├── clientResources.ts │ │ │ ├── parseDockerContainers.ts │ │ │ ├── parseDotNotation.ts │ │ │ ├── proxyResources.ts │ │ │ └── types.ts │ │ ├── cache.ts │ │ ├── calculateUserClientsForOrgs.ts │ │ ├── canUserAccessResource.ts │ │ ├── certificates.ts │ │ ├── checkOrgAccessPolicy.ts │ │ ├── cleanupLogs.test.ts │ │ ├── cleanupLogs.ts │ │ ├── clientVersionChecks.ts │ │ ├── colorsSchema.ts │ │ ├── config.ts │ │ ├── consts.ts │ │ ├── corsWithLoginPage.ts │ │ ├── crypto.ts │ │ ├── deleteOrg.ts │ │ ├── domainUtils.ts │ │ ├── encryption.ts │ │ ├── exitNodes/ │ │ │ ├── exitNodeComms.ts │ │ │ ├── exitNodes.ts │ │ │ ├── getCurrentExitNodeId.ts │ │ │ ├── index.ts │ │ │ └── subnet.ts │ │ ├── geoip.ts │ │ ├── getEnvOrYaml.ts │ │ ├── hostMeta.ts │ │ ├── idp/ │ │ │ └── generateRedirectUrl.ts │ │ ├── ip.test.ts │ │ ├── ip.ts │ │ ├── isLicencedOrSubscribed.ts │ │ ├── isSubscribed.ts │ │ ├── lock.ts │ │ ├── logAccessAudit.ts │ │ ├── normalizePostAuthPath.ts │ │ ├── rateLimitStore.ts │ │ ├── readConfigFile.ts │ │ ├── rebuildClientAssociations.ts │ │ ├── response.ts │ │ ├── s3.ts │ │ ├── schemas.ts │ │ ├── serverIpService.ts │ │ ├── sshCA.ts │ │ ├── stoi.ts │ │ ├── telemetry.ts │ │ ├── totp.ts │ │ ├── traefik/ │ │ │ ├── TraefikConfigManager.ts │ │ │ ├── getTraefikConfig.ts │ │ │ ├── index.ts │ │ │ ├── middleware.ts │ │ │ ├── pathEncoding.test.ts │ │ │ ├── traefikConfig.test.ts │ │ │ └── utils.ts │ │ ├── userOrg.ts │ │ ├── validators.test.ts │ │ └── validators.ts │ ├── license/ │ │ └── license.ts │ ├── logger.ts │ ├── middlewares/ │ │ ├── csrfProtection.ts │ │ ├── formatError.ts │ │ ├── getUserOrgs.ts │ │ ├── index.ts │ │ ├── integration/ │ │ │ ├── index.ts │ │ │ ├── verifyAccessTokenAccess.ts │ │ │ ├── verifyApiKey.ts │ │ │ ├── verifyApiKeyApiKeyAccess.ts │ │ │ ├── verifyApiKeyClientAccess.ts │ │ │ ├── verifyApiKeyDomainAccess.ts │ │ │ ├── verifyApiKeyHasAction.ts │ │ │ ├── verifyApiKeyIdpAccess.ts │ │ │ ├── verifyApiKeyIsRoot.ts │ │ │ ├── verifyApiKeyOrgAccess.ts │ │ │ ├── verifyApiKeyResourceAccess.ts │ │ │ ├── verifyApiKeyRoleAccess.ts │ │ │ ├── verifyApiKeySetResourceClients.ts │ │ │ ├── verifyApiKeySetResourceUsers.ts │ │ │ ├── verifyApiKeySiteAccess.ts │ │ │ ├── verifyApiKeySiteResourceAccess.ts │ │ │ ├── verifyApiKeyTargetAccess.ts │ │ │ └── verifyApiKeyUserAccess.ts │ │ ├── logActionAudit.ts │ │ ├── logIncoming.ts │ │ ├── notFound.ts │ │ ├── requestTimeout.ts │ │ ├── stripDuplicateSessions.ts │ │ ├── verifyAccessTokenAccess.ts │ │ ├── verifyAdmin.ts │ │ ├── verifyApiKeyAccess.ts │ │ ├── verifyClientAccess.ts │ │ ├── verifyDomainAccess.ts │ │ ├── verifyIsLoggedInUser.ts │ │ ├── verifyLimits.ts │ │ ├── verifyOlmAccess.ts │ │ ├── verifyOrgAccess.ts │ │ ├── verifyResourceAccess.ts │ │ ├── verifyRoleAccess.ts │ │ ├── verifySession.ts │ │ ├── verifySetResourceClients.ts │ │ ├── verifySetResourceUsers.ts │ │ ├── verifySiteAccess.ts │ │ ├── verifySiteResourceAccess.ts │ │ ├── verifyTargetAccess.ts │ │ ├── verifyUser.ts │ │ ├── verifyUserAccess.ts │ │ ├── verifyUserHasAction.ts │ │ ├── verifyUserInRole.ts │ │ ├── verifyUserIsOrgOwner.ts │ │ └── verifyUserIsServerAdmin.ts │ ├── nextServer.ts │ ├── openApi.ts │ ├── private/ │ │ ├── auth/ │ │ │ └── sessions/ │ │ │ └── remoteExitNode.ts │ │ ├── cleanup.ts │ │ ├── lib/ │ │ │ ├── billing/ │ │ │ │ ├── createCustomer.ts │ │ │ │ ├── getOrgTierData.ts │ │ │ │ └── index.ts │ │ │ ├── blueprints/ │ │ │ │ └── MaintenanceSchema.ts │ │ │ ├── cache.ts │ │ │ ├── certificates.ts │ │ │ ├── checkOrgAccessPolicy.ts │ │ │ ├── config.ts │ │ │ ├── exitNodes/ │ │ │ │ ├── exitNodeComms.ts │ │ │ │ ├── exitNodes.ts │ │ │ │ └── index.ts │ │ │ ├── isLicencedOrSubscribed.ts │ │ │ ├── isSubscribed.ts │ │ │ ├── lock.ts │ │ │ ├── logAccessAudit.ts │ │ │ ├── rateLimit.test.ts │ │ │ ├── rateLimit.ts │ │ │ ├── rateLimitStore.ts │ │ │ ├── readConfigFile.ts │ │ │ ├── redis.ts │ │ │ ├── redisStore.ts │ │ │ ├── stripe.ts │ │ │ └── traefik/ │ │ │ ├── getTraefikConfig.ts │ │ │ └── index.ts │ │ ├── license/ │ │ │ ├── license.ts │ │ │ └── licenseJwt.ts │ │ ├── middlewares/ │ │ │ ├── index.ts │ │ │ ├── logActionAudit.ts │ │ │ ├── verifyCertificateAccess.ts │ │ │ ├── verifyIdpAccess.ts │ │ │ ├── verifyLoginPageAccess.ts │ │ │ ├── verifyRemoteExitNode.ts │ │ │ ├── verifyRemoteExitNodeAccess.ts │ │ │ ├── verifySubscription.ts │ │ │ └── verifyValidLicense.ts │ │ └── routers/ │ │ ├── approvals/ │ │ │ ├── countApprovals.ts │ │ │ ├── index.ts │ │ │ ├── listApprovals.ts │ │ │ └── processPendingApproval.ts │ │ ├── auditLogs/ │ │ │ ├── exportAccessAuditLog.ts │ │ │ ├── exportActionAuditLog.ts │ │ │ ├── index.ts │ │ │ ├── queryAccessAuditLog.ts │ │ │ └── queryActionAuditLog.ts │ │ ├── auth/ │ │ │ ├── getSessionTransferToken.ts │ │ │ ├── index.ts │ │ │ └── transferSession.ts │ │ ├── billing/ │ │ │ ├── changeTier.ts │ │ │ ├── createCheckoutSession.ts │ │ │ ├── createPortalSession.ts │ │ │ ├── featureLifecycle.ts │ │ │ ├── getOrgSubscriptions.ts │ │ │ ├── getOrgUsage.ts │ │ │ ├── hooks/ │ │ │ │ ├── getSubType.ts │ │ │ │ ├── handleCustomerCreated.ts │ │ │ │ ├── handleCustomerDeleted.ts │ │ │ │ ├── handleCustomerUpdated.ts │ │ │ │ ├── handleSubscriptionCreated.ts │ │ │ │ ├── handleSubscriptionDeleted.ts │ │ │ │ └── handleSubscriptionUpdated.ts │ │ │ ├── index.ts │ │ │ ├── internalGetOrgTier.ts │ │ │ ├── subscriptionLifecycle.ts │ │ │ └── webhooks.ts │ │ ├── certificates/ │ │ │ ├── createCertificate.ts │ │ │ ├── getCertificate.ts │ │ │ ├── index.ts │ │ │ └── restartCertificate.ts │ │ ├── domain/ │ │ │ ├── checkDomainNamespaceAvailability.ts │ │ │ ├── index.ts │ │ │ └── listDomainNamespaces.ts │ │ ├── external.ts │ │ ├── generatedLicense/ │ │ │ ├── generateNewEnterpriseLicense.ts │ │ │ ├── generateNewLicense.ts │ │ │ ├── index.ts │ │ │ └── listGeneratedLicenses.ts │ │ ├── gerbil/ │ │ │ └── createExitNode.ts │ │ ├── hybrid.ts │ │ ├── integration.ts │ │ ├── internal.ts │ │ ├── license/ │ │ │ ├── activateLicense.ts │ │ │ ├── deleteLicenseKey.ts │ │ │ ├── getLicenseStatus.ts │ │ │ ├── index.ts │ │ │ ├── listLicenseKeys.ts │ │ │ └── recheckStatus.ts │ │ ├── loginPage/ │ │ │ ├── createLoginPage.ts │ │ │ ├── deleteLoginPage.ts │ │ │ ├── deleteLoginPageBranding.ts │ │ │ ├── getLoginPage.ts │ │ │ ├── getLoginPageBranding.ts │ │ │ ├── index.ts │ │ │ ├── loadLoginPage.ts │ │ │ ├── loadLoginPageBranding.ts │ │ │ ├── updateLoginPage.ts │ │ │ └── upsertLoginPageBranding.ts │ │ ├── misc/ │ │ │ ├── index.ts │ │ │ └── sendSupportEmail.ts │ │ ├── org/ │ │ │ ├── index.ts │ │ │ └── sendUsageNotifications.ts │ │ ├── orgIdp/ │ │ │ ├── createOrgOidcIdp.ts │ │ │ ├── deleteOrgIdp.ts │ │ │ ├── getOrgIdp.ts │ │ │ ├── index.ts │ │ │ ├── listOrgIdps.ts │ │ │ └── updateOrgOidcIdp.ts │ │ ├── re-key/ │ │ │ ├── index.ts │ │ │ ├── reGenerateClientSecret.ts │ │ │ ├── reGenerateExitNodeSecret.ts │ │ │ └── reGenerateSiteSecret.ts │ │ ├── remoteExitNode/ │ │ │ ├── createRemoteExitNode.ts │ │ │ ├── deleteRemoteExitNode.ts │ │ │ ├── getRemoteExitNode.ts │ │ │ ├── getRemoteExitNodeToken.ts │ │ │ ├── handleRemoteExitNodePingMessage.ts │ │ │ ├── handleRemoteExitNodeRegisterMessage.ts │ │ │ ├── index.ts │ │ │ ├── listRemoteExitNodes.ts │ │ │ ├── pickRemoteExitNodeDefaults.ts │ │ │ └── quickStartRemoteExitNode.ts │ │ ├── resource/ │ │ │ ├── getMaintenanceInfo.ts │ │ │ └── index.ts │ │ ├── ssh/ │ │ │ ├── index.ts │ │ │ └── signSshKey.ts │ │ └── ws/ │ │ ├── index.ts │ │ ├── messageHandlers.ts │ │ └── ws.ts │ ├── routers/ │ │ ├── accessToken/ │ │ │ ├── deleteAccessToken.ts │ │ │ ├── generateAccessToken.ts │ │ │ ├── index.ts │ │ │ └── listAccessTokens.ts │ │ ├── apiKeys/ │ │ │ ├── createOrgApiKey.ts │ │ │ ├── createRootApiKey.ts │ │ │ ├── deleteApiKey.ts │ │ │ ├── deleteOrgApiKey.ts │ │ │ ├── getApiKey.ts │ │ │ ├── index.ts │ │ │ ├── listApiKeyActions.ts │ │ │ ├── listOrgApiKeys.ts │ │ │ ├── listRootApiKeys.ts │ │ │ ├── setApiKeyActions.ts │ │ │ └── setApiKeyOrgs.ts │ │ ├── auditLogs/ │ │ │ ├── exportRequestAuditLog.ts │ │ │ ├── generateCSV.ts │ │ │ ├── index.ts │ │ │ ├── queryRequestAnalytics.ts │ │ │ ├── queryRequestAuditLog.ts │ │ │ └── types.ts │ │ ├── auth/ │ │ │ ├── changePassword.ts │ │ │ ├── checkResourceSession.ts │ │ │ ├── deleteMyAccount.ts │ │ │ ├── disable2fa.ts │ │ │ ├── index.ts │ │ │ ├── initialSetupComplete.ts │ │ │ ├── login.ts │ │ │ ├── logout.ts │ │ │ ├── lookupUser.ts │ │ │ ├── pollDeviceWebAuth.ts │ │ │ ├── requestEmailVerificationCode.ts │ │ │ ├── requestPasswordReset.ts │ │ │ ├── requestTotpSecret.ts │ │ │ ├── resetPassword.ts │ │ │ ├── securityKey.ts │ │ │ ├── setServerAdmin.ts │ │ │ ├── signup.ts │ │ │ ├── startDeviceWebAuth.ts │ │ │ ├── types.ts │ │ │ ├── validateSetupToken.ts │ │ │ ├── verifyDeviceWebAuth.ts │ │ │ ├── verifyEmail.ts │ │ │ └── verifyTotp.ts │ │ ├── badger/ │ │ │ ├── exchangeSession.ts │ │ │ ├── index.ts │ │ │ ├── logRequestAudit.ts │ │ │ ├── verifySession.test.ts │ │ │ └── verifySession.ts │ │ ├── billing/ │ │ │ ├── types.ts │ │ │ └── webhooks.ts │ │ ├── blueprints/ │ │ │ ├── applyJSONBlueprint.ts │ │ │ ├── applyYAMLBlueprint.ts │ │ │ ├── getBlueprint.ts │ │ │ ├── index.ts │ │ │ ├── listBlueprints.ts │ │ │ └── types.ts │ │ ├── certificates/ │ │ │ ├── createCertificate.ts │ │ │ └── types.ts │ │ ├── client/ │ │ │ ├── archiveClient.ts │ │ │ ├── blockClient.ts │ │ │ ├── createClient.ts │ │ │ ├── createUserClient.ts │ │ │ ├── deleteClient.ts │ │ │ ├── getClient.ts │ │ │ ├── index.ts │ │ │ ├── listClients.ts │ │ │ ├── listUserDevices.ts │ │ │ ├── pickClientDefaults.ts │ │ │ ├── targets.ts │ │ │ ├── terminate.ts │ │ │ ├── unarchiveClient.ts │ │ │ ├── unblockClient.ts │ │ │ └── updateClient.ts │ │ ├── domain/ │ │ │ ├── createOrgDomain.ts │ │ │ ├── deleteOrgDomain.ts │ │ │ ├── getDNSRecords.ts │ │ │ ├── getDomain.ts │ │ │ ├── index.ts │ │ │ ├── listDomains.ts │ │ │ ├── restartOrgDomain.ts │ │ │ ├── types.ts │ │ │ └── updateDomain.ts │ │ ├── external.ts │ │ ├── generatedLicense/ │ │ │ └── types.ts │ │ ├── gerbil/ │ │ │ ├── createExitNode.ts │ │ │ ├── getAllRelays.ts │ │ │ ├── getConfig.ts │ │ │ ├── getResolvedHostname.ts │ │ │ ├── index.ts │ │ │ ├── peers.ts │ │ │ ├── receiveBandwidth.ts │ │ │ └── updateHolePunch.ts │ │ ├── hybrid.ts │ │ ├── idp/ │ │ │ ├── createIdpOrgPolicy.ts │ │ │ ├── createOidcIdp.ts │ │ │ ├── deleteIdp.ts │ │ │ ├── deleteIdpOrgPolicy.ts │ │ │ ├── generateOidcUrl.ts │ │ │ ├── getIdp.ts │ │ │ ├── index.ts │ │ │ ├── listIdpOrgPolicies.ts │ │ │ ├── listIdps.ts │ │ │ ├── updateIdpOrgPolicy.ts │ │ │ ├── updateOidcIdp.ts │ │ │ └── validateOidcCallback.ts │ │ ├── integration.ts │ │ ├── internal.ts │ │ ├── license/ │ │ │ └── types.ts │ │ ├── loginPage/ │ │ │ └── types.ts │ │ ├── newt/ │ │ │ ├── buildConfiguration.ts │ │ │ ├── createNewt.ts │ │ │ ├── dockerSocket.ts │ │ │ ├── getNewtToken.ts │ │ │ ├── handleApplyBlueprintMessage.ts │ │ │ ├── handleGetConfigMessage.ts │ │ │ ├── handleNewtDisconnectingMessage.ts │ │ │ ├── handleNewtPingMessage.ts │ │ │ ├── handleNewtPingRequestMessage.ts │ │ │ ├── handleNewtRegisterMessage.ts │ │ │ ├── handleReceiveBandwidthMessage.ts │ │ │ ├── handleSocketMessages.ts │ │ │ ├── index.ts │ │ │ ├── peers.ts │ │ │ ├── sync.ts │ │ │ └── targets.ts │ │ ├── olm/ │ │ │ ├── archiveUserOlm.ts │ │ │ ├── buildConfiguration.ts │ │ │ ├── createOlm.ts │ │ │ ├── createUserOlm.ts │ │ │ ├── deleteUserOlm.ts │ │ │ ├── error.ts │ │ │ ├── fingerprintingUtils.ts │ │ │ ├── getOlmToken.ts │ │ │ ├── getUserOlm.ts │ │ │ ├── handleOlmDisconnectingMessage.ts │ │ │ ├── handleOlmPingMessage.ts │ │ │ ├── handleOlmRegisterMessage.ts │ │ │ ├── handleOlmRelayMessage.ts │ │ │ ├── handleOlmServerInitAddPeerHandshake.ts │ │ │ ├── handleOlmServerPeerAddMessage.ts │ │ │ ├── handleOlmUnRelayMessage.ts │ │ │ ├── index.ts │ │ │ ├── listUserOlms.ts │ │ │ ├── peers.ts │ │ │ ├── recoverOlmWithFingerprint.ts │ │ │ ├── sync.ts │ │ │ └── unarchiveUserOlm.ts │ │ ├── org/ │ │ │ ├── checkId.ts │ │ │ ├── checkOrgUserAccess.ts │ │ │ ├── createOrg.ts │ │ │ ├── deleteOrg.ts │ │ │ ├── getOrg.ts │ │ │ ├── getOrgOverview.ts │ │ │ ├── index.ts │ │ │ ├── listOrgs.ts │ │ │ ├── listUserOrgs.ts │ │ │ ├── pickOrgDefaults.ts │ │ │ ├── resetOrgBandwidth.ts │ │ │ └── updateOrg.ts │ │ ├── orgIdp/ │ │ │ └── types.ts │ │ ├── remoteExitNode/ │ │ │ └── types.ts │ │ ├── resource/ │ │ │ ├── addEmailToResourceWhitelist.ts │ │ │ ├── addRoleToResource.ts │ │ │ ├── addUserToResource.ts │ │ │ ├── authWithAccessToken.ts │ │ │ ├── authWithPassword.ts │ │ │ ├── authWithPincode.ts │ │ │ ├── authWithWhitelist.ts │ │ │ ├── createResource.ts │ │ │ ├── createResourceRule.ts │ │ │ ├── deleteResource.ts │ │ │ ├── deleteResourceRule.ts │ │ │ ├── getExchangeToken.ts │ │ │ ├── getResource.ts │ │ │ ├── getResourceAuthInfo.ts │ │ │ ├── getResourceWhitelist.ts │ │ │ ├── getUserResources.ts │ │ │ ├── index.ts │ │ │ ├── listAllResourceNames.ts │ │ │ ├── listResourceRoles.ts │ │ │ ├── listResourceRules.ts │ │ │ ├── listResourceUsers.ts │ │ │ ├── listResources.ts │ │ │ ├── removeEmailFromResourceWhitelist.ts │ │ │ ├── removeRoleFromResource.ts │ │ │ ├── removeUserFromResource.ts │ │ │ ├── setResourceHeaderAuth.ts │ │ │ ├── setResourcePassword.ts │ │ │ ├── setResourcePincode.ts │ │ │ ├── setResourceRoles.ts │ │ │ ├── setResourceUsers.ts │ │ │ ├── setResourceWhitelist.ts │ │ │ ├── types.ts │ │ │ ├── updateResource.ts │ │ │ └── updateResourceRule.ts │ │ ├── role/ │ │ │ ├── addRoleAction.ts │ │ │ ├── addRoleSite.ts │ │ │ ├── createRole.ts │ │ │ ├── deleteRole.ts │ │ │ ├── getRole.ts │ │ │ ├── index.ts │ │ │ ├── listRoleActions.ts │ │ │ ├── listRoleResources.ts │ │ │ ├── listRoleSites.ts │ │ │ ├── listRoles.ts │ │ │ ├── removeRoleAction.ts │ │ │ ├── removeRoleResource.ts │ │ │ ├── removeRoleSite.ts │ │ │ └── updateRole.ts │ │ ├── serverInfo/ │ │ │ ├── getServerInfo.ts │ │ │ └── index.ts │ │ ├── site/ │ │ │ ├── createSite.ts │ │ │ ├── deleteSite.ts │ │ │ ├── getSite.ts │ │ │ ├── index.ts │ │ │ ├── listSiteRoles.ts │ │ │ ├── listSites.ts │ │ │ ├── pickSiteDefaults.ts │ │ │ ├── socketIntegration.ts │ │ │ └── updateSite.ts │ │ ├── siteResource/ │ │ │ ├── addClientToSiteResource.ts │ │ │ ├── addRoleToSiteResource.ts │ │ │ ├── addUserToSiteResource.ts │ │ │ ├── batchAddClientToSiteResources.ts │ │ │ ├── createSiteResource.ts │ │ │ ├── deleteSiteResource.ts │ │ │ ├── getSiteResource.ts │ │ │ ├── index.ts │ │ │ ├── listAllSiteResourcesByOrg.ts │ │ │ ├── listSiteResourceClients.ts │ │ │ ├── listSiteResourceRoles.ts │ │ │ ├── listSiteResourceUsers.ts │ │ │ ├── listSiteResources.ts │ │ │ ├── removeClientFromSiteResource.ts │ │ │ ├── removeRoleFromSiteResource.ts │ │ │ ├── removeUserFromSiteResource.ts │ │ │ ├── setSiteResourceClients.ts │ │ │ ├── setSiteResourceRoles.ts │ │ │ ├── setSiteResourceUsers.ts │ │ │ └── updateSiteResource.ts │ │ ├── supporterKey/ │ │ │ ├── hideSupporterKey.ts │ │ │ ├── index.ts │ │ │ ├── isSupporterKeyVisible.ts │ │ │ └── validateSupporterKey.ts │ │ ├── target/ │ │ │ ├── createTarget.ts │ │ │ ├── deleteTarget.ts │ │ │ ├── getTarget.ts │ │ │ ├── handleHealthcheckStatusMessage.ts │ │ │ ├── helpers.ts │ │ │ ├── index.ts │ │ │ ├── listTargets.ts │ │ │ └── updateTarget.ts │ │ ├── traefik/ │ │ │ ├── configSchema.ts │ │ │ ├── index.ts │ │ │ └── traefikConfigProvider.ts │ │ ├── user/ │ │ │ ├── acceptInvite.ts │ │ │ ├── addUserAction.ts │ │ │ ├── addUserRole.ts │ │ │ ├── addUserSite.ts │ │ │ ├── adminGeneratePasswordResetCode.ts │ │ │ ├── adminGetUser.ts │ │ │ ├── adminListUsers.ts │ │ │ ├── adminRemoveUser.ts │ │ │ ├── adminUpdateUser2FA.ts │ │ │ ├── createOrgUser.ts │ │ │ ├── getOrgUser.ts │ │ │ ├── getOrgUserByUsername.ts │ │ │ ├── getUser.ts │ │ │ ├── index.ts │ │ │ ├── inviteUser.ts │ │ │ ├── listInvitations.ts │ │ │ ├── listUsers.ts │ │ │ ├── myDevice.ts │ │ │ ├── removeInvitation.ts │ │ │ ├── removeUserAction.ts │ │ │ ├── removeUserOrg.ts │ │ │ ├── removeUserResource.ts │ │ │ ├── removeUserSite.ts │ │ │ └── updateOrgUser.ts │ │ └── ws/ │ │ ├── checkRoundTripMessage.ts │ │ ├── handleRoundTripMessage.ts │ │ ├── index.ts │ │ ├── messageHandlers.ts │ │ ├── types.ts │ │ └── ws.ts │ ├── setup/ │ │ ├── .gitignore │ │ ├── clearStaleData.ts │ │ ├── copyInConfig.ts │ │ ├── ensureActions.ts │ │ ├── ensureSetupToken.ts │ │ ├── index.ts │ │ ├── migrationsPg.ts │ │ ├── migrationsSqlite.ts │ │ ├── scriptsPg/ │ │ │ ├── 1.10.0.ts │ │ │ ├── 1.10.2.ts │ │ │ ├── 1.11.0.ts │ │ │ ├── 1.11.1.ts │ │ │ ├── 1.12.0.ts │ │ │ ├── 1.13.0.ts │ │ │ ├── 1.14.0.ts │ │ │ ├── 1.15.0.ts │ │ │ ├── 1.15.3.ts │ │ │ ├── 1.15.4.ts │ │ │ ├── 1.16.0.ts │ │ │ ├── 1.6.0.ts │ │ │ ├── 1.7.0.ts │ │ │ ├── 1.8.0.ts │ │ │ └── 1.9.0.ts │ │ └── scriptsSqlite/ │ │ ├── 1.0.0-beta1.ts │ │ ├── 1.0.0-beta10.ts │ │ ├── 1.0.0-beta12.ts │ │ ├── 1.0.0-beta13.ts │ │ ├── 1.0.0-beta15.ts │ │ ├── 1.0.0-beta2.ts │ │ ├── 1.0.0-beta3.ts │ │ ├── 1.0.0-beta5.ts │ │ ├── 1.0.0-beta6.ts │ │ ├── 1.0.0-beta9.ts │ │ ├── 1.0.0.ts │ │ ├── 1.1.0.ts │ │ ├── 1.10.0.ts │ │ ├── 1.10.1.ts │ │ ├── 1.10.2.ts │ │ ├── 1.11.0.ts │ │ ├── 1.11.1.ts │ │ ├── 1.12.0.ts │ │ ├── 1.13.0.ts │ │ ├── 1.14.0.ts │ │ ├── 1.15.0.ts │ │ ├── 1.15.3.ts │ │ ├── 1.15.4.ts │ │ ├── 1.16.0.ts │ │ ├── 1.2.0.ts │ │ ├── 1.3.0.ts │ │ ├── 1.5.0.ts │ │ ├── 1.6.0.ts │ │ ├── 1.7.0.ts │ │ ├── 1.8.0.ts │ │ └── 1.9.0.ts │ └── types/ │ ├── ArrayElement.ts │ ├── Auth.ts │ ├── ErrorResponse.ts │ ├── HttpCode.ts │ ├── MessageResponse.ts │ ├── Pagination.ts │ ├── Response.ts │ ├── Tiers.ts │ └── UserTypes.ts ├── src/ │ ├── actions/ │ │ └── server.ts │ ├── app/ │ │ ├── [orgId]/ │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── settings/ │ │ │ ├── (private)/ │ │ │ │ ├── access/ │ │ │ │ │ └── approvals/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── billing/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── idp/ │ │ │ │ │ ├── [idpId]/ │ │ │ │ │ │ ├── general/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── create/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── license/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── remote-exit-nodes/ │ │ │ │ ├── [remoteExitNodeId]/ │ │ │ │ │ ├── credentials/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── create/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── access/ │ │ │ │ ├── invitations/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── roles/ │ │ │ │ │ └── page.tsx │ │ │ │ └── users/ │ │ │ │ ├── [userId]/ │ │ │ │ │ ├── access-controls/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── create/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── api-keys/ │ │ │ │ ├── [apiKeyId]/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── permissions/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── create/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── blueprints/ │ │ │ │ ├── [blueprintId]/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── create/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── clients/ │ │ │ │ ├── layout.tsx │ │ │ │ ├── machine/ │ │ │ │ │ ├── [niceId]/ │ │ │ │ │ │ ├── credentials/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── general/ │ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── create/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── user/ │ │ │ │ ├── [niceId]/ │ │ │ │ │ ├── general/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── domains/ │ │ │ │ ├── [domainId]/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── general/ │ │ │ │ ├── auth-page/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── security/ │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── logs/ │ │ │ │ ├── access/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── action/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── analytics/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── request/ │ │ │ │ └── page.tsx │ │ │ ├── not-found.tsx │ │ │ ├── page.tsx │ │ │ ├── resources/ │ │ │ │ ├── client/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── proxy/ │ │ │ │ ├── [niceId]/ │ │ │ │ │ ├── authentication/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── general/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ ├── proxy/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ └── rules/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── create/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── share-links/ │ │ │ │ └── page.tsx │ │ │ └── sites/ │ │ │ ├── [niceId]/ │ │ │ │ ├── credentials/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── general/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ ├── page.tsx │ │ │ │ └── wireguardConfig.ts │ │ │ ├── create/ │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── admin/ │ │ │ ├── api-keys/ │ │ │ │ ├── [apiKeyId]/ │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── permissions/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── create/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── idp/ │ │ │ │ ├── [idpId]/ │ │ │ │ │ ├── general/ │ │ │ │ │ │ └── page.tsx │ │ │ │ │ ├── layout.tsx │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── policies/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── create/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── license/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ └── users/ │ │ │ ├── AdminUsersTable.tsx │ │ │ ├── [userId]/ │ │ │ │ ├── general/ │ │ │ │ │ └── page.tsx │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── auth/ │ │ │ ├── 2fa/ │ │ │ │ └── setup/ │ │ │ │ └── page.tsx │ │ │ ├── delete-account/ │ │ │ │ ├── DeleteAccountClient.tsx │ │ │ │ └── page.tsx │ │ │ ├── idp/ │ │ │ │ └── [idpId]/ │ │ │ │ └── oidc/ │ │ │ │ └── callback/ │ │ │ │ └── page.tsx │ │ │ ├── initial-setup/ │ │ │ │ ├── layout.tsx │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── login/ │ │ │ │ ├── device/ │ │ │ │ │ ├── page.tsx │ │ │ │ │ └── success/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── org/ │ │ │ │ ├── [orgId]/ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── reset-password/ │ │ │ │ ├── ResetPasswordForm.tsx │ │ │ │ └── page.tsx │ │ │ ├── resource/ │ │ │ │ └── [resourceGuid]/ │ │ │ │ └── page.tsx │ │ │ ├── signup/ │ │ │ │ └── page.tsx │ │ │ └── verify-email/ │ │ │ └── page.tsx │ │ ├── globals.css │ │ ├── invite/ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── maintenance-screen/ │ │ │ └── page.tsx │ │ ├── navigation.tsx │ │ ├── not-found.tsx │ │ ├── page.tsx │ │ ├── robots.ts │ │ ├── s/ │ │ │ └── [accessToken]/ │ │ │ └── page.tsx │ │ └── setup/ │ │ ├── layout.tsx │ │ └── page.tsx │ ├── components/ │ │ ├── AccessPageHeaderAndNav.tsx │ │ ├── AccessToken.tsx │ │ ├── AccessTokenUsage.tsx │ │ ├── ActionBanner.tsx │ │ ├── AdminIdpDataTable.tsx │ │ ├── AdminIdpTable.tsx │ │ ├── AdminUsersDataTable.tsx │ │ ├── AdminUsersTable.tsx │ │ ├── ApiKeysDataTable.tsx │ │ ├── ApiKeysTable.tsx │ │ ├── ApplyInternalRedirect.tsx │ │ ├── ApprovalFeed.tsx │ │ ├── ApprovalsBanner.tsx │ │ ├── ApprovalsEmptyState.tsx │ │ ├── AuthPageBrandingForm.tsx │ │ ├── AuthPageSettings.tsx │ │ ├── AutoLoginHandler.tsx │ │ ├── AutoProvisionConfigWidget.tsx │ │ ├── BlueprintDetailsForm.tsx │ │ ├── BlueprintsTable.tsx │ │ ├── BrandingLogo.tsx │ │ ├── CertificateStatus.tsx │ │ ├── ChangePasswordDialog.tsx │ │ ├── ChangePasswordForm.tsx │ │ ├── ClientDownloadBanner.tsx │ │ ├── ClientInfoCard.tsx │ │ ├── ClientResourcesTable.tsx │ │ ├── ClientsDataTable.tsx │ │ ├── ColumnFilter.tsx │ │ ├── ColumnFilterButton.tsx │ │ ├── ConfirmDeleteDialog.tsx │ │ ├── ContainersSelector.tsx │ │ ├── CopyTextBox.tsx │ │ ├── CopyToClipboard.tsx │ │ ├── CreateBlueprintForm.tsx │ │ ├── CreateDomainForm.tsx │ │ ├── CreateInternalResourceDialog.tsx │ │ ├── CreateRoleForm.tsx │ │ ├── CreateShareLinkForm.tsx │ │ ├── Credenza.tsx │ │ ├── CustomDomainInput.tsx │ │ ├── DNSRecordTable.tsx │ │ ├── DNSRecordsDataTable.tsx │ │ ├── DashboardLoginForm.tsx │ │ ├── DataTablePagination.tsx │ │ ├── DateTimePicker.tsx │ │ ├── DeleteAccountConfirmDialog.tsx │ │ ├── DeleteRoleForm.tsx │ │ ├── DeviceAuthConfirmation.tsx │ │ ├── DeviceLoginForm.tsx │ │ ├── Disable2FaForm.tsx │ │ ├── DismissableBanner.tsx │ │ ├── DomainCertForm.tsx │ │ ├── DomainInfoCard.tsx │ │ ├── DomainPicker.tsx │ │ ├── DomainsDataTable.tsx │ │ ├── DomainsTable.tsx │ │ ├── EditInternalResourceDialog.tsx │ │ ├── EditRoleForm.tsx │ │ ├── Enable2FaDialog.tsx │ │ ├── Enable2FaForm.tsx │ │ ├── ExitNodeInfoCard.tsx │ │ ├── ExitNodesDataTable.tsx │ │ ├── ExitNodesTable.tsx │ │ ├── GenerateLicenseKeyForm.tsx │ │ ├── GenerateLicenseKeysTable.tsx │ │ ├── HeadersInput.tsx │ │ ├── HealthCheckDialog.tsx │ │ ├── HorizontalTabs.tsx │ │ ├── IdpCreateWizard.tsx │ │ ├── IdpGlobalModeBanner.tsx │ │ ├── IdpLoginButtons.tsx │ │ ├── IdpTypeBadge.tsx │ │ ├── InfoSection.tsx │ │ ├── InternalResourceForm.tsx │ │ ├── InvitationsDataTable.tsx │ │ ├── InvitationsTable.tsx │ │ ├── InviteStatusCard.tsx │ │ ├── Layout.tsx │ │ ├── LayoutHeader.tsx │ │ ├── LayoutMobileMenu.tsx │ │ ├── LayoutSidebar.tsx │ │ ├── LicenseKeysDataTable.tsx │ │ ├── LicenseViolation.tsx │ │ ├── LocaleSwitcher.tsx │ │ ├── LocaleSwitcherSelect.tsx │ │ ├── LogAnalyticsData.tsx │ │ ├── LogDataTable.tsx │ │ ├── LoginCardHeader.tsx │ │ ├── LoginForm.tsx │ │ ├── LoginOrgSelector.tsx │ │ ├── LoginPasswordForm.tsx │ │ ├── MachineClientsBanner.tsx │ │ ├── MachineClientsTable.tsx │ │ ├── MemberResourcesPortal.tsx │ │ ├── MfaInputForm.tsx │ │ ├── NewPricingLicenseForm.tsx │ │ ├── OptionSelect.tsx │ │ ├── OrgApiKeysDataTable.tsx │ │ ├── OrgApiKeysTable.tsx │ │ ├── OrgIdpDataTable.tsx │ │ ├── OrgIdpTable.tsx │ │ ├── OrgInfoCard.tsx │ │ ├── OrgLoginPage.tsx │ │ ├── OrgPolicyRequired.tsx │ │ ├── OrgPolicyResult.tsx │ │ ├── OrgSelectionForm.tsx │ │ ├── OrgSelector.tsx │ │ ├── OrgSignInLink.tsx │ │ ├── OrganizationLanding.tsx │ │ ├── OrganizationLandingCard.tsx │ │ ├── PaidFeaturesAlert.tsx │ │ ├── PathMatchRenameModal.tsx │ │ ├── PermissionsSelectBox.tsx │ │ ├── PlaceHolderLoader.tsx │ │ ├── PolicyDataTable.tsx │ │ ├── PolicyTable.tsx │ │ ├── PrivateResourcesBanner.tsx │ │ ├── ProductUpdates.tsx │ │ ├── ProfessionalContentOverlay.tsx │ │ ├── ProfileIcon.tsx │ │ ├── ProxyResourcesBanner.tsx │ │ ├── ProxyResourcesTable.tsx │ │ ├── QRContainer.tsx │ │ ├── RedirectToOrg.tsx │ │ ├── RefreshButton.tsx │ │ ├── RegenerateInvitationForm.tsx │ │ ├── RegionSelector.tsx │ │ ├── ResetPasswordForm.tsx │ │ ├── ResourceAccessDenied.tsx │ │ ├── ResourceAuthPortal.tsx │ │ ├── ResourceInfoBox.tsx │ │ ├── ResourceNotFound.tsx │ │ ├── RestartDomainButton.tsx │ │ ├── RoleForm.tsx │ │ ├── RolesDataTable.tsx │ │ ├── RolesTable.tsx │ │ ├── SecurityKeyAuthButton.tsx │ │ ├── SecurityKeyForm.tsx │ │ ├── SetLastOrgCookie.tsx │ │ ├── SetResourceHeaderAuthForm.tsx │ │ ├── SetResourcePasswordForm.tsx │ │ ├── SetResourcePincodeForm.tsx │ │ ├── Settings.tsx │ │ ├── SettingsSectionTitle.tsx │ │ ├── ShareLinksDataTable.tsx │ │ ├── ShareLinksSplash.tsx │ │ ├── ShareLinksTable.tsx │ │ ├── SidebarLicenseButton.tsx │ │ ├── SidebarNav.tsx │ │ ├── SidebarSupportButton.tsx │ │ ├── SignupForm.tsx │ │ ├── SiteInfoCard.tsx │ │ ├── SitePriceCalculator.tsx │ │ ├── SitesBanner.tsx │ │ ├── SitesSplashCard.tsx │ │ ├── SitesTable.tsx │ │ ├── SmartLoginForm.tsx │ │ ├── SplashImage.tsx │ │ ├── StoreInternalRedirect.tsx │ │ ├── StrategySelect.tsx │ │ ├── SubscriptionViolation.tsx │ │ ├── SupporterMessage.tsx │ │ ├── SupporterStatus.tsx │ │ ├── SwitchInput.tsx │ │ ├── TailwindIndicator.tsx │ │ ├── TanstackQueryProvider.tsx │ │ ├── ThemeSwitcher.tsx │ │ ├── TopbarNav.tsx │ │ ├── Toploader.tsx │ │ ├── TwoFactorSetupForm.tsx │ │ ├── UserDevicesTable.tsx │ │ ├── UserProfileCard.tsx │ │ ├── UsersDataTable.tsx │ │ ├── UsersTable.tsx │ │ ├── ValidateOidcToken.tsx │ │ ├── ValidateSessionTransferToken.tsx │ │ ├── VerifyEmailForm.tsx │ │ ├── ViewDevicesDialog.tsx │ │ ├── ViewportHeightFix.tsx │ │ ├── WorldMap.tsx │ │ ├── newt-install-commands.tsx │ │ ├── olm-install-commands.tsx │ │ ├── resource-target-address-item.tsx │ │ ├── tags/ │ │ │ ├── autocomplete.tsx │ │ │ ├── tag-input.tsx │ │ │ ├── tag-list.tsx │ │ │ ├── tag-popover.tsx │ │ │ └── tag.tsx │ │ └── ui/ │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── chart.tsx │ │ ├── checkbox.tsx │ │ ├── collapsible.tsx │ │ ├── command.tsx │ │ ├── controlled-data-table.tsx │ │ ├── data-table.tsx │ │ ├── dialog.tsx │ │ ├── drawer.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── info-popup.tsx │ │ ├── input-otp.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── radio-group.tsx │ │ ├── scroll-area.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ └── tooltip.tsx │ ├── contexts/ │ │ ├── apiKeyContext.ts │ │ ├── clientContext.ts │ │ ├── domainContext.ts │ │ ├── envContext.ts │ │ ├── licenseStatusContext.ts │ │ ├── orgContext.ts │ │ ├── orgUserContext.ts │ │ ├── remoteExitNodeContext.ts │ │ ├── resourceContext.ts │ │ ├── siteContext.ts │ │ ├── subscriptionStatusContext.ts │ │ ├── supporterStatusContext.ts │ │ └── userContext.ts │ ├── hooks/ │ │ ├── useApikeyContext.ts │ │ ├── useCertificate.ts │ │ ├── useClientContext.ts │ │ ├── useDomainContext.ts │ │ ├── useEnvContext.ts │ │ ├── useLicenseStatusContext.ts │ │ ├── useLocalStorage.ts │ │ ├── useMediaQuery.ts │ │ ├── useNavigationContext.ts │ │ ├── useOrgContext.ts │ │ ├── useOrgUserContext.ts │ │ ├── usePaidStatus.ts │ │ ├── useRemoteExitNodeContext.ts │ │ ├── useResourceContext.ts │ │ ├── useSiteContext.ts │ │ ├── useStoredColumnVisibility.ts │ │ ├── useStoredPageSize.ts │ │ ├── useSubscriptionStatusContext.ts │ │ ├── useSupporterStatusContext.ts │ │ ├── useToast.ts │ │ ├── useUserContext.ts │ │ └── useUserLookup.ts │ ├── i18n/ │ │ ├── config.ts │ │ └── request.ts │ ├── lib/ │ │ ├── api/ │ │ │ ├── cookies.ts │ │ │ ├── formatAxiosError.ts │ │ │ ├── getCachedOrg.ts │ │ │ ├── getCachedOrgUser.ts │ │ │ ├── getCachedSubscription.ts │ │ │ ├── index.ts │ │ │ └── isOrgSubscribed.ts │ │ ├── auth/ │ │ │ └── verifySession.ts │ │ ├── cleanRedirect.ts │ │ ├── cn.ts │ │ ├── countryCodeList.ts │ │ ├── countryCodeToFlagEmoji.ts │ │ ├── dataSize.ts │ │ ├── docker.ts │ │ ├── durationToMs.ts │ │ ├── formatDeviceFingerprint.ts │ │ ├── getSevenDaysAgo.ts │ │ ├── getUserDisplayName.ts │ │ ├── internalRedirect.ts │ │ ├── parseHostTarget.ts │ │ ├── pullEnv.ts │ │ ├── queries.ts │ │ ├── replacePlaceholder.ts │ │ ├── shareLinks.ts │ │ ├── sortColumn.ts │ │ ├── subdomain-utils.ts │ │ ├── themeColors.ts │ │ ├── timeAgoFormatter.ts │ │ ├── types/ │ │ │ ├── env.ts │ │ │ └── sort.ts │ │ ├── validateLocalPath.ts │ │ ├── wait.ts │ │ └── wireguard.ts │ ├── middleware.ts │ ├── providers/ │ │ ├── ApiKeyProvider.tsx │ │ ├── ClientProvider.tsx │ │ ├── DomainProvider.tsx │ │ ├── EnvProvider.tsx │ │ ├── LicenseStatusProvider.tsx │ │ ├── OrgProvider.tsx │ │ ├── OrgUserProvider.tsx │ │ ├── RemoteExitNodeProvider.tsx │ │ ├── ResourceProvider.tsx │ │ ├── SiteProvider.tsx │ │ ├── SubscriptionStatusProvider.tsx │ │ ├── SupporterStatusProvider.tsx │ │ ├── ThemeDataProvider.tsx │ │ ├── ThemeProvider.tsx │ │ └── UserProvider.tsx │ ├── services/ │ │ └── locale.ts │ └── types/ │ ├── canvas-confetti.d.ts │ └── tanstack-query.d.ts ├── test/ │ └── assert.ts ├── tsconfig.enterprise.json ├── tsconfig.oss.json └── tsconfig.saas.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ /node_modules /.pnp .pnp.js .yarn/install-state.gz /coverage /.next/ /out/ /build .DS_Store *.pem npm-debug.log* yarn-debug.log* yarn-error.log* .env*.local .env .vercel *.tsbuildinfo next-env.d.ts *.db *.sqlite *.sqlite3 *.log .machinelogs*.json *-audit.json install/ bruno/ LICENSE CONTRIBUTING.md dist .git server/migrations/ config/ build.ts tsconfig.json Dockerfile* drizzle.config.ts ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true [*.ts] quote_type = double [*.md] max_line_length = off trim_trailing_whitespace = false ================================================ FILE: .eslintrc.json ================================================ { "extends": ["next/core-web-vitals", "next/typescript"] } ================================================ FILE: .github/DISCUSSION_TEMPLATE/feature-requests.yml ================================================ body: - type: textarea attributes: label: Summary description: A clear and concise summary of the requested feature. validations: required: true - type: textarea attributes: label: Motivation description: | Why is this feature important? Explain the problem this feature would solve or what use case it would enable. validations: required: true - type: textarea attributes: label: Proposed Solution description: | How would you like to see this feature implemented? Provide as much detail as possible about the desired behavior, configuration, or changes. validations: required: true - type: textarea attributes: label: Alternatives Considered description: Describe any alternative solutions or workarounds you've thought about. validations: required: false - type: textarea attributes: label: Additional Context description: Add any other context, mockups, or screenshots about the feature request here. validations: required: false - type: markdown attributes: value: | Before submitting, please: - Check if there is an existing issue for this feature. - Clearly explain the benefit and use case. - Be as specific as possible to help contributors evaluate and implement. ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: [fosrl] ================================================ FILE: .github/ISSUE_TEMPLATE/1.bug_report.yml ================================================ name: Bug Report description: Create a bug report labels: [] body: - type: textarea attributes: label: Describe the Bug description: A clear and concise description of what the bug is. validations: required: true - type: textarea attributes: label: Environment description: Please fill out the relevant details below for your environment. value: | - OS Type & Version: (e.g., Ubuntu 22.04) - Pangolin Version: - Gerbil Version: - Traefik Version: - Newt Version: - Olm Version: (if applicable) validations: required: true - type: textarea attributes: label: To Reproduce description: | Steps to reproduce the behavior, please provide a clear description of how to reproduce the issue, based on the linked minimal reproduction. Screenshots can be provided in the issue body below. If using code blocks, make sure syntax highlighting is correct and double-check that the rendered preview is not broken. validations: required: true - type: textarea attributes: label: Expected Behavior description: A clear and concise description of what you expected to happen. validations: required: true - type: markdown attributes: value: | Before posting the issue go through the steps you've written down to make sure the steps provided are detailed and clear. - type: markdown attributes: value: | Contributors should be able to follow the steps provided in order to reproduce the bug. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Need help or have questions? url: https://github.com/orgs/fosrl/discussions about: Ask questions, get help, and discuss with other community members - name: Request a Feature url: https://github.com/orgs/fosrl/discussions/new?category=feature-requests about: Feature requests should be opened as discussions so others can upvote and comment ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Community Contribution License Agreement By creating this pull request, I grant the project maintainers an unlimited, perpetual license to use, modify, and redistribute these contributions under any terms they choose, including both the AGPLv3 and the Fossorial Commercial license terms. I represent that I have the right to grant this license for all contributed content. ## Description ## How to test? ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "daily" groups: dev-patch-updates: dependency-type: "development" update-types: - "patch" dev-minor-updates: dependency-type: "development" update-types: - "minor" prod-patch-updates: dependency-type: "production" update-types: - "patch" prod-minor-updates: dependency-type: "production" update-types: - "minor" - package-ecosystem: "docker" directory: "/" schedule: interval: "daily" groups: patch-updates: update-types: - "patch" minor-updates: update-types: - "minor" - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" - package-ecosystem: "gomod" directory: "/install" schedule: interval: "daily" groups: patch-updates: update-types: - "patch" minor-updates: update-types: - "minor" ================================================ FILE: .github/workflows/cicd.yml ================================================ name: Public CICD Pipeline # CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries. # Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events. permissions: contents: read packages: write # for GHCR push id-token: write # for Cosign Keyless (OIDC) Signing # Required secrets: # - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN: push to Docker Hub # - GITHUB_TOKEN: used for GHCR login and OIDC keyless signing # - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY: for key-based signing on: push: tags: - "[0-9]+.[0-9]+.[0-9]+" - "[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" concurrency: group: ${{ github.ref }} cancel-in-progress: true jobs: pre-run: runs-on: ubuntu-latest permissions: write-all steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 aws-region: ${{ secrets.AWS_REGION }} - name: Verify AWS identity run: aws sts get-caller-identity - name: Start EC2 instances run: | aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }} echo "EC2 instances started" release-arm: name: Build and Release (ARM64) runs-on: [self-hosted, linux, arm64, us-east-1] needs: [pre-run] if: >- ${{ needs.pre-run.result == 'success' }} # Job-level timeout to avoid runaway or stuck runs timeout-minutes: 120 env: # Target images DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }} GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Monitor storage space run: | THRESHOLD=75 USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g') echo "Used space: $USED_SPACE%" if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then echo "Used space is below the threshold of 75% free. Running Docker system prune." echo y | docker system prune -a else echo "Storage space is above the threshold. No action needed." fi - name: Log in to Docker Hub uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Extract tag name id: get-tag run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV shell: bash - name: Update version in package.json run: | TAG=${{ env.TAG }} sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts cat server/lib/consts.ts shell: bash - name: Check if release candidate id: check-rc run: | TAG=${{ env.TAG }} if [[ "$TAG" == *"-rc."* ]]; then echo "IS_RC=true" >> $GITHUB_ENV else echo "IS_RC=false" >> $GITHUB_ENV fi shell: bash - name: Build and push Docker images (Docker Hub - ARM64) run: | TAG=${{ env.TAG }} if [ "$IS_RC" = "true" ]; then make build-rc-arm tag=$TAG else make build-release-arm tag=$TAG fi echo "Built & pushed ARM64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" shell: bash release-amd: name: Build and Release (AMD64) runs-on: [self-hosted, linux, x64, us-east-1] needs: [pre-run] if: >- ${{ needs.pre-run.result == 'success' }} # Job-level timeout to avoid runaway or stuck runs timeout-minutes: 120 env: # Target images DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }} GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Monitor storage space run: | THRESHOLD=75 USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g') echo "Used space: $USED_SPACE%" if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then echo "Used space is below the threshold of 75% free. Running Docker system prune." echo y | docker system prune -a else echo "Storage space is above the threshold. No action needed." fi - name: Log in to Docker Hub uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Extract tag name id: get-tag run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV shell: bash - name: Update version in package.json run: | TAG=${{ env.TAG }} sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts cat server/lib/consts.ts shell: bash - name: Check if release candidate id: check-rc run: | TAG=${{ env.TAG }} if [[ "$TAG" == *"-rc."* ]]; then echo "IS_RC=true" >> $GITHUB_ENV else echo "IS_RC=false" >> $GITHUB_ENV fi shell: bash - name: Build and push Docker images (Docker Hub - AMD64) run: | TAG=${{ env.TAG }} if [ "$IS_RC" = "true" ]; then make build-rc-amd tag=$TAG else make build-release-amd tag=$TAG fi echo "Built & pushed AMD64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" shell: bash create-manifest: name: Create Multi-Arch Manifests runs-on: [self-hosted, linux, x64, us-east-1] needs: [release-arm, release-amd] if: >- ${{ needs.release-arm.result == 'success' && needs.release-amd.result == 'success' }} timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Log in to Docker Hub uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} - name: Extract tag name id: get-tag run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV shell: bash - name: Check if release candidate id: check-rc run: | TAG=${{ env.TAG }} if [[ "$TAG" == *"-rc."* ]]; then echo "IS_RC=true" >> $GITHUB_ENV else echo "IS_RC=false" >> $GITHUB_ENV fi shell: bash - name: Create multi-arch manifests run: | TAG=${{ env.TAG }} if [ "$IS_RC" = "true" ]; then make create-manifests-rc tag=$TAG else make create-manifests tag=$TAG fi echo "Created multi-arch manifests for tag: ${TAG}" shell: bash sign-and-package: name: Sign and Package runs-on: [self-hosted, linux, x64, us-east-1] needs: [release-arm, release-amd, create-manifest] if: >- ${{ needs.release-arm.result == 'success' && needs.release-amd.result == 'success' && needs.create-manifest.result == 'success' }} # Job-level timeout to avoid runaway or stuck runs timeout-minutes: 120 env: # Target images DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }} GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Extract tag name id: get-tag run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV shell: bash - name: Install Go uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: 1.24 - name: Update version in package.json run: | TAG=${{ env.TAG }} sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts cat server/lib/consts.ts shell: bash - name: Pull latest Gerbil version id: get-gerbil-tag run: | LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV shell: bash - name: Pull latest Badger version id: get-badger-tag run: | LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV shell: bash - name: Build installer working-directory: install run: | make go-build-release \ PANGOLIN_VERSION=${{ env.TAG }} \ GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} \ BADGER_VERSION=${{ env.LATEST_BADGER_TAG }} shell: bash - name: Upload artifacts from /install/bin uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: install-bin path: install/bin/ - name: Install skopeo + jq # skopeo: copy/inspect images between registries # jq: JSON parsing tool used to extract digest values run: | sudo apt-get update -y sudo apt-get install -y skopeo jq skopeo --version shell: bash - name: Login to GHCR env: REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json run: | mkdir -p "$(dirname "$REGISTRY_AUTH_FILE")" skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" shell: bash - name: Copy tags from Docker Hub to GHCR # Mirror the already-built images (all architectures) to GHCR so we can sign them # Wait a bit for both architectures to be available in Docker Hub manifest env: REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json run: | set -euo pipefail TAG=${{ env.TAG }} MAJOR_TAG=$(echo $TAG | cut -d. -f1) MINOR_TAG=$(echo $TAG | cut -d. -f1,2) echo "Waiting for multi-arch manifests to be ready..." sleep 30 # Determine if this is an RC release IS_RC="false" if [[ "$TAG" == *"-rc."* ]]; then IS_RC="true" fi if [ "$IS_RC" = "true" ]; then echo "RC release detected - copying version-specific tags only" # SQLite OSS echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" skopeo copy --all --retry-times 3 \ docker://$DOCKERHUB_IMAGE:$TAG \ docker://$GHCR_IMAGE:$TAG # PostgreSQL OSS echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG}" skopeo copy --all --retry-times 3 \ docker://$DOCKERHUB_IMAGE:postgresql-$TAG \ docker://$GHCR_IMAGE:postgresql-$TAG # SQLite Enterprise echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-${TAG}" skopeo copy --all --retry-times 3 \ docker://$DOCKERHUB_IMAGE:ee-$TAG \ docker://$GHCR_IMAGE:ee-$TAG # PostgreSQL Enterprise echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG}" skopeo copy --all --retry-times 3 \ docker://$DOCKERHUB_IMAGE:ee-postgresql-$TAG \ docker://$GHCR_IMAGE:ee-postgresql-$TAG else echo "Regular release detected - copying all tags (latest, major, minor, full version)" # SQLite OSS - all tags for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:${TAG_SUFFIX}" skopeo copy --all --retry-times 3 \ docker://$DOCKERHUB_IMAGE:$TAG_SUFFIX \ docker://$GHCR_IMAGE:$TAG_SUFFIX done # PostgreSQL OSS - all tags for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG_SUFFIX}" skopeo copy --all --retry-times 3 \ docker://$DOCKERHUB_IMAGE:postgresql-$TAG_SUFFIX \ docker://$GHCR_IMAGE:postgresql-$TAG_SUFFIX done # SQLite Enterprise - all tags for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-${TAG_SUFFIX}" skopeo copy --all --retry-times 3 \ docker://$DOCKERHUB_IMAGE:ee-$TAG_SUFFIX \ docker://$GHCR_IMAGE:ee-$TAG_SUFFIX done # PostgreSQL Enterprise - all tags for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG_SUFFIX}" skopeo copy --all --retry-times 3 \ docker://$DOCKERHUB_IMAGE:ee-postgresql-$TAG_SUFFIX \ docker://$GHCR_IMAGE:ee-postgresql-$TAG_SUFFIX done fi echo "All images copied successfully to GHCR!" shell: bash - name: Login to GitHub Container Registry (for cosign) uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Install cosign # cosign is used to sign and verify container images (key and keyless) uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Dual-sign and verify (GHCR & Docker Hub) # Sign each image by digest using keyless (OIDC) and key-based signing, # then verify both the public key signature and the keyless OIDC signature. env: TAG: ${{ env.TAG }} COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} COSIGN_YES: "true" run: | set -euo pipefail issuer="https://token.actions.githubusercontent.com" id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs) # Track failures FAILED_TAGS=() SUCCESSFUL_TAGS=() # Determine if this is an RC release IS_RC="false" if [[ "$TAG" == *"-rc."* ]]; then IS_RC="true" fi # Define image variants to sign if [ "$IS_RC" = "true" ]; then echo "RC release - signing version-specific tags only" IMAGE_TAGS=( "${TAG}" "postgresql-${TAG}" "ee-${TAG}" "ee-postgresql-${TAG}" ) else echo "Regular release - signing all tags" MAJOR_TAG=$(echo $TAG | cut -d. -f1) MINOR_TAG=$(echo $TAG | cut -d. -f1,2) IMAGE_TAGS=( "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG" "postgresql-latest" "postgresql-$MAJOR_TAG" "postgresql-$MINOR_TAG" "postgresql-$TAG" "ee-latest" "ee-$MAJOR_TAG" "ee-$MINOR_TAG" "ee-$TAG" "ee-postgresql-latest" "ee-postgresql-$MAJOR_TAG" "ee-postgresql-$MINOR_TAG" "ee-postgresql-$TAG" ) fi # Sign each image variant for both registries for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}" TAG_FAILED=false # Wrap the entire tag processing in error handling ( set -e DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')" REF="${BASE_IMAGE}@${DIGEST}" echo "Resolved digest: ${REF}" echo "==> cosign sign (keyless) --recursive ${REF}" cosign sign --recursive "${REF}" echo "==> cosign sign (key) --recursive ${REF}" cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" # Retry wrapper for verification to handle registry propagation delays retry_verify() { local cmd="$1" local attempts=6 local delay=5 local i=1 until eval "$cmd"; do if [ $i -ge $attempts ]; then echo "Verification failed after $attempts attempts" return 1 fi echo "Verification not yet available. Retry $i/$attempts after ${delay}s..." sleep $delay i=$((i+1)) delay=$((delay*2)) # Cap the delay to avoid very long waits if [ $delay -gt 60 ]; then delay=60; fi done return 0 } echo "==> cosign verify (public key) ${REF}" if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then VERIFIED_INDEX=true else VERIFIED_INDEX=false fi echo "==> cosign verify (keyless policy) ${REF}" if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then VERIFIED_INDEX_KEYLESS=true else VERIFIED_INDEX_KEYLESS=false fi # Check if verification succeeded if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}" echo "This may be due to registry propagation delays. Continuing anyway." fi ) || TAG_FAILED=true if [ "$TAG_FAILED" = "true" ]; then echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}" FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}") else echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}" SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}") fi done done # Report summary echo "" echo "==========================================" echo "Sign and Verify Summary" echo "==========================================" echo "Successful: ${#SUCCESSFUL_TAGS[@]}" echo "Failed: ${#FAILED_TAGS[@]}" echo "" if [ ${#FAILED_TAGS[@]} -gt 0 ]; then echo "Failed tags:" for tag in "${FAILED_TAGS[@]}"; do echo " - $tag" done echo "" echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway" else echo "✓ All images signed and verified successfully!" fi shell: bash post-run: needs: [pre-run, release-arm, release-amd, create-manifest, sign-and-package] if: >- ${{ always() && needs.pre-run.result == 'success' && (needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure') && (needs.release-amd.result == 'success' || needs.release-amd.result == 'skipped' || needs.release-amd.result == 'failure') && (needs.create-manifest.result == 'success' || needs.create-manifest.result == 'skipped' || needs.create-manifest.result == 'failure') && (needs.sign-and-package.result == 'success' || needs.sign-and-package.result == 'skipped' || needs.sign-and-package.result == 'failure') }} runs-on: ubuntu-latest permissions: write-all steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 aws-region: ${{ secrets.AWS_REGION }} - name: Verify AWS identity run: aws sts get-caller-identity - name: Stop EC2 instances run: | aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }} echo "EC2 instances stopped" ================================================ FILE: .github/workflows/linting.yml ================================================ name: ESLint permissions: contents: read on: pull_request: paths: - '**/*.js' - '**/*.jsx' - '**/*.ts' - '**/*.tsx' - '.eslintrc*' - 'package.json' - 'yarn.lock' - 'pnpm-lock.yaml' - 'package-lock.json' jobs: Linter: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Node.js uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: '24' - name: Install dependencies run: npm ci - name: Create build file run: npm run set:oss - name: Run ESLint run: npx eslint . --ext .js,.jsx,.ts,.tsx ================================================ FILE: .github/workflows/mirror.yaml ================================================ name: Mirror & Sign (Docker Hub to GHCR) on: workflow_dispatch: {} permissions: contents: read packages: write id-token: write # for keyless OIDC env: SOURCE_IMAGE: docker.io/fosrl/pangolin DEST_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} jobs: mirror-and-dual-sign: runs-on: amd64-runner steps: - name: Install skopeo + jq run: | sudo apt-get update -y sudo apt-get install -y skopeo jq skopeo --version - name: Install cosign uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Input check run: | test -n "${SOURCE_IMAGE}" || (echo "SOURCE_IMAGE is empty" && exit 1) echo "Source : ${SOURCE_IMAGE}" echo "Target : ${DEST_IMAGE}" # Auth for skopeo (containers-auth) - name: Skopeo login to GHCR run: | skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" # Auth for cosign (docker-config) - name: Docker login to GHCR (for cosign) run: | echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - name: List source tags run: | set -euo pipefail skopeo list-tags --retry-times 3 docker://"${SOURCE_IMAGE}" \ | jq -r '.Tags[]' | grep -v -e '-arm64' -e '-amd64' | sort -u > src-tags.txt echo "Found source tags: $(wc -l < src-tags.txt)" head -n 20 src-tags.txt || true - name: List destination tags (skip existing) run: | set -euo pipefail if skopeo list-tags --retry-times 3 docker://"${DEST_IMAGE}" >/tmp/dst.json 2>/dev/null; then jq -r '.Tags[]' /tmp/dst.json | sort -u > dst-tags.txt else : > dst-tags.txt fi echo "Existing destination tags: $(wc -l < dst-tags.txt)" - name: Mirror, dual-sign, and verify env: # keyless COSIGN_YES: "true" # key-based COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} # verify COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} run: | set -euo pipefail copied=0; skipped=0; v_ok=0; errs=0 issuer="https://token.actions.githubusercontent.com" id_regex="^https://github.com/${{ github.repository }}/.+" while read -r tag; do [ -z "$tag" ] && continue if grep -Fxq "$tag" dst-tags.txt; then echo "::notice ::Skip (exists) ${DEST_IMAGE}:${tag}" skipped=$((skipped+1)) continue fi echo "==> Copy ${SOURCE_IMAGE}:${tag} → ${DEST_IMAGE}:${tag}" if ! skopeo copy --all --retry-times 3 \ docker://"${SOURCE_IMAGE}:${tag}" docker://"${DEST_IMAGE}:${tag}"; then echo "::warning title=Copy failed::${SOURCE_IMAGE}:${tag}" errs=$((errs+1)); continue fi copied=$((copied+1)) digest="$(skopeo inspect --retry-times 3 docker://"${DEST_IMAGE}:${tag}" | jq -r '.Digest')" ref="${DEST_IMAGE}@${digest}" echo "==> cosign sign (keyless) --recursive ${ref}" if ! cosign sign --recursive "${ref}"; then echo "::warning title=Keyless sign failed::${ref}" errs=$((errs+1)) fi echo "==> cosign sign (key) --recursive ${ref}" if ! cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${ref}"; then echo "::warning title=Key sign failed::${ref}" errs=$((errs+1)) fi echo "==> cosign verify (public key) ${ref}" if ! cosign verify --key env://COSIGN_PUBLIC_KEY "${ref}" -o text; then echo "::warning title=Verify(pubkey) failed::${ref}" errs=$((errs+1)) fi echo "==> cosign verify (keyless policy) ${ref}" if ! cosign verify \ --certificate-oidc-issuer "${issuer}" \ --certificate-identity-regexp "${id_regex}" \ "${ref}" -o text; then echo "::warning title=Verify(keyless) failed::${ref}" errs=$((errs+1)) else v_ok=$((v_ok+1)) fi done < src-tags.txt echo "---- Summary ----" echo "Copied : $copied" echo "Skipped : $skipped" echo "Verified OK : $v_ok" echo "Errors : $errs" ================================================ FILE: .github/workflows/restart-runners.yml ================================================ name: Restart Runners on: schedule: - cron: '0 0 */7 * *' permissions: id-token: write contents: read jobs: ec2-maintenance-prod: runs-on: ubuntu-latest permissions: write-all steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 aws-region: ${{ secrets.AWS_REGION }} - name: Verify AWS identity run: aws sts get-caller-identity - name: Start EC2 instance run: | aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }} echo "EC2 instances started" - name: Wait run: sleep 600 - name: Stop EC2 instance run: | aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }} echo "EC2 instances stopped" ================================================ FILE: .github/workflows/saas.yml ================================================ name: SAAS Pipeline # CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries. # Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events. permissions: contents: read packages: write # for GHCR push id-token: write # for Cosign Keyless (OIDC) Signing on: push: tags: - "[0-9]+.[0-9]+.[0-9]+-s.[0-9]+" concurrency: group: ${{ github.ref }} cancel-in-progress: true jobs: pre-run: runs-on: ubuntu-latest permissions: write-all steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 aws-region: ${{ secrets.AWS_REGION }} - name: Verify AWS identity run: aws sts get-caller-identity - name: Start EC2 instances run: | aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} echo "EC2 instances started" release-arm: name: Build and Release (ARM64) runs-on: [self-hosted, linux, arm64, us-east-1] needs: [pre-run] if: >- ${{ needs.pre-run.result == 'success' }} # Job-level timeout to avoid runaway or stuck runs timeout-minutes: 120 env: # Target images AWS_IMAGE: ${{ secrets.aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com/${{ github.event.repository.name }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download MaxMind GeoLite2 databases env: MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }} run: | echo "Downloading MaxMind GeoLite2 databases..." # Download GeoLite2-Country curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \ -o GeoLite2-Country.tar.gz # Download GeoLite2-ASN curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \ -o GeoLite2-ASN.tar.gz # Extract the .mmdb files tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb' tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb' # Verify files exist if [ ! -f "GeoLite2-Country.mmdb" ]; then echo "ERROR: Failed to download GeoLite2-Country.mmdb" exit 1 fi if [ ! -f "GeoLite2-ASN.mmdb" ]; then echo "ERROR: Failed to download GeoLite2-ASN.mmdb" exit 1 fi # Clean up tar files rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz echo "MaxMind databases downloaded successfully" ls -lh GeoLite2-*.mmdb - name: Monitor storage space run: | THRESHOLD=75 USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g') echo "Used space: $USED_SPACE%" if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then echo "Used space is below the threshold of 75% free. Running Docker system prune." echo y | docker system prune -a else echo "Storage space is above the threshold. No action needed." fi - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 aws-region: ${{ secrets.AWS_REGION }} - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 - name: Extract tag name id: get-tag run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV shell: bash - name: Update version in package.json run: | TAG=${{ env.TAG }} sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts cat server/lib/consts.ts shell: bash - name: Build and push Docker images (Docker Hub - ARM64) run: | TAG=${{ env.TAG }} make build-saas tag=$TAG echo "Built & pushed ARM64 images to: ${{ env.AWS_IMAGE }}:${TAG}" shell: bash post-run: needs: [pre-run, release-arm] if: >- ${{ always() && needs.pre-run.result == 'success' && (needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure') }} runs-on: ubuntu-latest permissions: write-all steps: - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 aws-region: ${{ secrets.AWS_REGION }} - name: Verify AWS identity run: aws sts get-caller-identity - name: Stop EC2 instances run: | aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} echo "EC2 instances stopped" ================================================ FILE: .github/workflows/stale-bot.yml ================================================ name: Mark and Close Stale Issues on: schedule: - cron: '0 0 * * *' workflow_dispatch: # Allow manual trigger permissions: contents: write # only for delete-branch option issues: write pull-requests: write jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: days-before-stale: 14 days-before-close: 14 stale-issue-message: 'This issue has been automatically marked as stale due to 14 days of inactivity. It will be closed in 14 days if no further activity occurs.' close-issue-message: 'This issue has been automatically closed due to inactivity. If you believe this is still relevant, please open a new issue with up-to-date information.' stale-issue-label: 'stale' exempt-issue-labels: 'needs investigating, networking, new feature, reverse proxy, bug, api, authentication, documentation, enhancement, help wanted, good first issue, question' exempt-all-issue-assignees: true only-labels: '' exempt-pr-labels: '' days-before-pr-stale: -1 days-before-pr-close: -1 operations-per-run: 100 remove-stale-when-updated: true delete-branch: false enable-statistics: true ================================================ FILE: .github/workflows/test.yml ================================================ name: Run Tests permissions: contents: read on: pull_request: branches: - main - dev jobs: test: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Node uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: '24' - name: Copy config file run: cp config/config.example.yml config/config.yml - name: Install dependencies run: npm ci - name: Create database index.ts run: npm run set:sqlite - name: Create build file run: npm run set:oss - name: Generate database migrations run: npm run db:generate - name: Apply database migrations run: npm run db:push - name: Test with tsc run: npx tsc --noEmit - name: Start app in background run: nohup npm run dev & - name: Wait for app availability run: | for i in {1..5}; do if curl --silent --fail http://localhost:3002/auth/login; then echo "App is up" exit 0 fi echo "Waiting for the app... attempt $i" sleep 5 done echo "App failed to start" exit 1 build-sqlite: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Build Docker image sqlite run: make dev-build-sqlite build-postgres: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Build Docker image pg run: make dev-build-pg ================================================ FILE: .gitignore ================================================ /node_modules /.pnp .pnp.js .yarn/install-state.gz /coverage /.next/ /out/ /build .DS_Store *.pem npm-debug.log* yarn-debug.log* yarn-error.log* .env*.local .env .vercel *.tsbuildinfo next-env.d.ts *.db *.sqlite !Dockerfile.sqlite *.sqlite3 *.log .machinelogs*.json *-audit.json migrations tsconfig.tsbuildinfo config/config.yml config/config.saas.yml config/config.oss.yml config/config.enterprise.yml config/privateConfig.yml config/postgres config/postgres* config/openapi.yaml config/key dist .dist installer *.tar bin .secrets test_event.json .idea/ public/branding server/db/index.ts server/build.ts postgres/ dynamic/ *.mmdb scratch/ tsconfig.json hydrateSaas.ts CLAUDE.md drizzle.config.ts server/setup/migrations.ts ================================================ FILE: .nvmrc ================================================ 24 ================================================ FILE: .prettierignore ================================================ .github/ bruno/ cli/ config/ messages/ next.config.mjs/ public/ tailwind.config.js/ test/ **/*.yml **/*.yaml **/*.md ================================================ FILE: .prettierrc ================================================ { "tabWidth": 4, "printWidth": 80, "trailingComma": "none" } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["esbenp.prettier-vscode"] } ================================================ FILE: .vscode/settings.json ================================================ { "editor.codeActionsOnSave": { "source.addMissingImports.ts": "always" }, "editor.defaultFormatter": "esbenp.prettier-vscode", "[jsonc]": { "editor.defaultFormatter": "vscode.json-language-features" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "editor.formatOnSave": true } ================================================ FILE: CONTRIBUTING.md ================================================ ## Contributing Contributions are welcome! Please see the contribution and local development guide on the docs page before getting started: https://docs.pangolin.net/development/contributing ### Licensing Considerations Please note that your contributions will be distributed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us. At the beginning of all pull requests please place the following contributor license agreement (CLA). For larger submissions, we may reach out with a more formal agreement. ``` By creating this pull request, I grant the project maintainers an unlimited, perpetual license to use, modify, and redistribute these contributions under any terms they choose, including both the AGPLv3 and the Fossorial Commercial license terms. I represent that I have the right to grant this license for all contributed content. ``` ================================================ FILE: Dockerfile ================================================ # FROM node:24-slim AS base FROM public.ecr.aws/docker/library/node:24-slim AS base WORKDIR /app RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* 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 && \ test -f dist/server.mjs # Create placeholder files for MaxMind databases to avoid COPY errors # Real files should be present for saas builds, placeholders for oss builds RUN touch /app/GeoLite2-Country.mmdb /app/GeoLite2-ASN.mmdb FROM base AS builder RUN npm ci --omit=dev # FROM node:24-slim AS runner FROM public.ecr.aws/docker/library/node:24-slim AS runner WORKDIR /app RUN apt-get update && apt-get install -y curl tzdata && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/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 # Copy MaxMind databases for SaaS builds ARG BUILD=oss RUN mkdir -p ./maxmind # Copy MaxMind databases (placeholders exist for oss builds, real files for saas) COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb # Remove MaxMind databases for non-saas builds (keep only for saas) RUN if [ "$BUILD" != "saas" ]; then rm -rf ./maxmind; fi # OCI Image Labels - Build Args for dynamic values ARG VERSION="dev" ARG REVISION="" ARG CREATED="" ARG LICENSE="AGPL-3.0" # Derive title and description based on BUILD type ARG IMAGE_TITLE="Pangolin" ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" # OCI Image Labels # https://github.com/opencontainers/image-spec/blob/main/annotations.md LABEL org.opencontainers.image.source="https://github.com/fosrl/pangolin" \ org.opencontainers.image.url="https://github.com/fosrl/pangolin" \ org.opencontainers.image.documentation="https://docs.pangolin.net" \ org.opencontainers.image.vendor="Fossorial" \ org.opencontainers.image.licenses="${LICENSE}" \ org.opencontainers.image.title="${IMAGE_TITLE}" \ org.opencontainers.image.description="${IMAGE_DESCRIPTION}" \ org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.revision="${REVISION}" \ org.opencontainers.image.created="${CREATED}" CMD ["npm", "run", "start"] ================================================ FILE: Dockerfile.dev ================================================ FROM node:24-alpine WORKDIR /app RUN apk add --no-cache python3 make g++ COPY package*.json ./ # Install dependencies RUN npm ci # Copy source code COPY . . # Use tsx watch for development with hot reload CMD ["npm", "run", "dev"] ================================================ FILE: LICENSE ================================================ Copyright (c) 2025 Fossorial, Inc. Portions of this software are licensed as follows: * All files that include a header specifying they are licensed under the "Fossorial Commercial License" are governed by the Fossorial Commercial License terms. The specific terms applicable to each customer depend on the commercial license tier agreed upon in writing with Fossorial, Inc. Unauthorized use, copying, modification, or distribution is strictly prohibited. * All files that include a header specifying they are licensed under the GNU Affero General Public License, Version 3 ("AGPL-3"), are governed by the AGPL-3 terms. A full copy of the AGPL-3 license is provided below. However, these files are also available under the Fossorial Commercial License if a separate commercial license agreement has been executed between the customer and Fossorial, Inc. * All files without a license header are, by default, licensed under the GNU Affero General Public License, Version 3 (AGPL-3). These files may also be made available under the Fossorial Commercial License upon agreement with Fossorial, Inc. * All third-party components included in this repository are licensed under their respective original licenses, as provided by their authors. Please consult the header of each individual file to determine the applicable license. For AGPL-3 licensed files, dual-licensing under the Fossorial Commercial License is available subject to written agreement with Fossorial, Inc. GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: Makefile ================================================ .PHONY: build build-pg build-release build-release-arm build-release-amd create-manifests build-arm build-x86 test clean major_tag := $(shell echo $(tag) | cut -d. -f1) minor_tag := $(shell echo $(tag) | cut -d. -f1,2) # OCI label variables CREATED := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") # Common OCI build args for OSS builds OCI_ARGS_OSS = --build-arg VERSION=$(tag) \ --build-arg REVISION=$(REVISION) \ --build-arg CREATED=$(CREATED) \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" # Common OCI build args for Enterprise builds OCI_ARGS_EE = --build-arg VERSION=$(tag) \ --build-arg REVISION=$(REVISION) \ --build-arg CREATED=$(CREATED) \ --build-arg LICENSE="Fossorial Commercial" \ --build-arg IMAGE_TITLE="Pangolin EE" \ --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" .PHONY: build-release build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql build-release: build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql build-sqlite: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ fi docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ $(OCI_ARGS_OSS) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:latest \ --tag fosrl/pangolin:$(major_tag) \ --tag fosrl/pangolin:$(minor_tag) \ --tag fosrl/pangolin:$(tag) \ --push . build-postgresql: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ fi docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ $(OCI_ARGS_OSS) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:postgresql-latest \ --tag fosrl/pangolin:postgresql-$(major_tag) \ --tag fosrl/pangolin:postgresql-$(minor_tag) \ --tag fosrl/pangolin:postgresql-$(tag) \ --push . build-ee-sqlite: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ fi docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ $(OCI_ARGS_EE) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-latest \ --tag fosrl/pangolin:ee-$(major_tag) \ --tag fosrl/pangolin:ee-$(minor_tag) \ --tag fosrl/pangolin:ee-$(tag) \ --push . build-ee-postgresql: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ fi docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ $(OCI_ARGS_EE) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-postgresql-latest \ --tag fosrl/pangolin:ee-postgresql-$(major_tag) \ --tag fosrl/pangolin:ee-postgresql-$(minor_tag) \ --tag fosrl/pangolin:ee-postgresql-$(tag) \ --push . build-saas: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ fi docker buildx build \ --build-arg BUILD=saas \ --build-arg DATABASE=pg \ --platform linux/arm64 \ --tag $(AWS_IMAGE):$(tag) \ --push . build-release-arm: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release-arm tag="; \ exit 1; \ fi @MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \ MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \ CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:latest-arm64 \ --tag fosrl/pangolin:$$MAJOR_TAG-arm64 \ --tag fosrl/pangolin:$$MINOR_TAG-arm64 \ --tag fosrl/pangolin:$(tag)-arm64 \ --push . && \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:postgresql-latest-arm64 \ --tag fosrl/pangolin:postgresql-$$MAJOR_TAG-arm64 \ --tag fosrl/pangolin:postgresql-$$MINOR_TAG-arm64 \ --tag fosrl/pangolin:postgresql-$(tag)-arm64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg LICENSE="Fossorial Commercial" \ --build-arg IMAGE_TITLE="Pangolin EE" \ --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:ee-latest-arm64 \ --tag fosrl/pangolin:ee-$$MAJOR_TAG-arm64 \ --tag fosrl/pangolin:ee-$$MINOR_TAG-arm64 \ --tag fosrl/pangolin:ee-$(tag)-arm64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg LICENSE="Fossorial Commercial" \ --build-arg IMAGE_TITLE="Pangolin EE" \ --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:ee-postgresql-latest-arm64 \ --tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG-arm64 \ --tag fosrl/pangolin:ee-postgresql-$$MINOR_TAG-arm64 \ --tag fosrl/pangolin:ee-postgresql-$(tag)-arm64 \ --push . build-release-amd: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release-amd tag="; \ exit 1; \ fi @MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \ MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \ CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:latest-amd64 \ --tag fosrl/pangolin:$$MAJOR_TAG-amd64 \ --tag fosrl/pangolin:$$MINOR_TAG-amd64 \ --tag fosrl/pangolin:$(tag)-amd64 \ --push . && \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:postgresql-latest-amd64 \ --tag fosrl/pangolin:postgresql-$$MAJOR_TAG-amd64 \ --tag fosrl/pangolin:postgresql-$$MINOR_TAG-amd64 \ --tag fosrl/pangolin:postgresql-$(tag)-amd64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg LICENSE="Fossorial Commercial" \ --build-arg IMAGE_TITLE="Pangolin EE" \ --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:ee-latest-amd64 \ --tag fosrl/pangolin:ee-$$MAJOR_TAG-amd64 \ --tag fosrl/pangolin:ee-$$MINOR_TAG-amd64 \ --tag fosrl/pangolin:ee-$(tag)-amd64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg LICENSE="Fossorial Commercial" \ --build-arg IMAGE_TITLE="Pangolin EE" \ --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:ee-postgresql-latest-amd64 \ --tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG-amd64 \ --tag fosrl/pangolin:ee-postgresql-$$MINOR_TAG-amd64 \ --tag fosrl/pangolin:ee-postgresql-$(tag)-amd64 \ --push . create-manifests: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make create-manifests tag="; \ exit 1; \ fi @MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \ MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \ echo "Creating multi-arch manifests for sqlite (oss)..." && \ docker buildx imagetools create \ --tag fosrl/pangolin:latest \ --tag fosrl/pangolin:$$MAJOR_TAG \ --tag fosrl/pangolin:$$MINOR_TAG \ --tag fosrl/pangolin:$(tag) \ fosrl/pangolin:latest-arm64 \ fosrl/pangolin:latest-amd64 && \ echo "Creating multi-arch manifests for postgresql (oss)..." && \ docker buildx imagetools create \ --tag fosrl/pangolin:postgresql-latest \ --tag fosrl/pangolin:postgresql-$$MAJOR_TAG \ --tag fosrl/pangolin:postgresql-$$MINOR_TAG \ --tag fosrl/pangolin:postgresql-$(tag) \ fosrl/pangolin:postgresql-latest-arm64 \ fosrl/pangolin:postgresql-latest-amd64 && \ echo "Creating multi-arch manifests for sqlite (enterprise)..." && \ docker buildx imagetools create \ --tag fosrl/pangolin:ee-latest \ --tag fosrl/pangolin:ee-$$MAJOR_TAG \ --tag fosrl/pangolin:ee-$$MINOR_TAG \ --tag fosrl/pangolin:ee-$(tag) \ fosrl/pangolin:ee-latest-arm64 \ fosrl/pangolin:ee-latest-amd64 && \ echo "Creating multi-arch manifests for postgresql (enterprise)..." && \ docker buildx imagetools create \ --tag fosrl/pangolin:ee-postgresql-latest \ --tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG \ --tag fosrl/pangolin:ee-postgresql-$$MINOR_TAG \ --tag fosrl/pangolin:ee-postgresql-$(tag) \ fosrl/pangolin:ee-postgresql-latest-arm64 \ fosrl/pangolin:ee-postgresql-latest-amd64 && \ echo "All multi-arch manifests created successfully!" build-rc: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ fi @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:$(tag) \ --push . && \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:postgresql-$(tag) \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg LICENSE="Fossorial Commercial" \ --build-arg IMAGE_TITLE="Pangolin EE" \ --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-$(tag) \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg LICENSE="Fossorial Commercial" \ --build-arg IMAGE_TITLE="Pangolin EE" \ --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-postgresql-$(tag) \ --push . build-rc-arm: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-rc-arm tag="; \ exit 1; \ fi @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:$(tag)-arm64 \ --push . && \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:postgresql-$(tag)-arm64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg LICENSE="Fossorial Commercial" \ --build-arg IMAGE_TITLE="Pangolin EE" \ --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:ee-$(tag)-arm64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg LICENSE="Fossorial Commercial" \ --build-arg IMAGE_TITLE="Pangolin EE" \ --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ --tag fosrl/pangolin:ee-postgresql-$(tag)-arm64 \ --push . build-rc-amd: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-rc-amd tag="; \ exit 1; \ fi @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:$(tag)-amd64 \ --push . && \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:postgresql-$(tag)-amd64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg LICENSE="Fossorial Commercial" \ --build-arg IMAGE_TITLE="Pangolin EE" \ --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:ee-$(tag)-amd64 \ --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ --build-arg VERSION=$(tag) \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg LICENSE="Fossorial Commercial" \ --build-arg IMAGE_TITLE="Pangolin EE" \ --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ --tag fosrl/pangolin:ee-postgresql-$(tag)-amd64 \ --push . create-manifests-rc: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make create-manifests-rc tag="; \ exit 1; \ fi @echo "Creating multi-arch manifests for RC sqlite (oss)..." && \ docker buildx imagetools create \ --tag fosrl/pangolin:$(tag) \ fosrl/pangolin:$(tag)-arm64 \ fosrl/pangolin:$(tag)-amd64 && \ echo "Creating multi-arch manifests for RC postgresql (oss)..." && \ docker buildx imagetools create \ --tag fosrl/pangolin:postgresql-$(tag) \ fosrl/pangolin:postgresql-$(tag)-arm64 \ fosrl/pangolin:postgresql-$(tag)-amd64 && \ echo "Creating multi-arch manifests for RC sqlite (enterprise)..." && \ docker buildx imagetools create \ --tag fosrl/pangolin:ee-$(tag) \ fosrl/pangolin:ee-$(tag)-arm64 \ fosrl/pangolin:ee-$(tag)-amd64 && \ echo "Creating multi-arch manifests for RC postgresql (enterprise)..." && \ docker buildx imagetools create \ --tag fosrl/pangolin:ee-postgresql-$(tag) \ fosrl/pangolin:ee-postgresql-$(tag)-arm64 \ fosrl/pangolin:ee-postgresql-$(tag)-amd64 && \ echo "All RC multi-arch manifests created successfully!" build-arm: @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg VERSION=dev \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64 \ -t fosrl/pangolin:latest . build-x86: @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg VERSION=dev \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/amd64 \ -t fosrl/pangolin:latest . dev-build-sqlite: @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker build \ --build-arg DATABASE=sqlite \ --build-arg VERSION=dev \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ -t fosrl/pangolin:latest . dev-build-pg: @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker build \ --build-arg DATABASE=pg \ --build-arg VERSION=dev \ --build-arg REVISION=$$REVISION \ --build-arg CREATED=$$CREATED \ --build-arg IMAGE_TITLE="Pangolin" \ --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ -t fosrl/pangolin:postgresql-latest . test: docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest clean: docker rmi pangolin ================================================ FILE: README.md ================================================
[![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4) [![Slack](https://img.shields.io/badge/chat-slack-yellow?style=flat-square&logo=slack)](https://pangolin.net/slack) [![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin) ![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square) [![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@pangolin-net)

We're Hiring!

Get started with Pangolin at app.pangolin.net

Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources, all with zero-trust security and granular access control. ## Installation - Check out the [quick install guide](https://docs.pangolin.net/self-host/quick-install) for how to install and set up Pangolin. - Install from the [DigitalOcean marketplace](https://marketplace.digitalocean.com/apps/pangolin-ce-1?refcode=edf0480eeb81) for a one-click pre-configured installer. ## Deployment Options | | Description | |-----------------|--------------| | **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. | | **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. | | **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. | ## Key Features | | | |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------| | **Connect remote networks with sites**

Pangolin's lightweight site connectors create secure tunnels from remote networks without requiring public IP addresses or open ports. Sites make any network anywhere available for authorized access. | | | **Browser-based reverse proxy access**

Expose web applications through identity and context-aware tunneled reverse proxies. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Users access applications through any web browser with authentication and granular access control. | | | **Client-based private resource access**

Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. | | | **Zero-trust granular access**

Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications and services you explicitly define, reducing security risk and attack surface. | | ## Download Clients Download the Pangolin client for your platform: - [Mac](https://pangolin.net/downloads/mac) - [Windows](https://pangolin.net/downloads/windows) - [Linux](https://pangolin.net/downloads/linux) - [iOS](https://pangolin.net/downloads/ios) - [Android](https://pangolin.net/downloads/android) ## Get Started ### Sign up now Create an account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud. A generous free tier is available. ### Check out the docs We encourage everyone to read the full documentation first, which is available at [docs.pangolin.net](https://docs.pangolin.net). This README provides only a very brief subset of the docs to illustrate some basic ideas. ## Licensing Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net). ## Contributions Please see [CONTRIBUTING](./CONTRIBUTING.md) in the repository for guidelines and best practices. --- WireGuard® is a registered trademark of Jason A. Donenfeld. ================================================ FILE: SECURITY.md ================================================ # Security Policy If you discover a security vulnerability, please follow the steps below to responsibly disclose it to us: 1. **Do not create a public GitHub issue or discussion post.** This could put the security of other users at risk. 2. Send a detailed report to [security@pangolin.net](mailto:security@pangolin.net) or send a **private** message to a maintainer on [Discord](https://discord.gg/HCJR8Xhme4). Include: - Description and location of the vulnerability. - Potential impact of the vulnerability. - Steps to reproduce the vulnerability. - Potential solutions to fix the vulnerability. - Your name/handle and a link for recognition (optional). We aim to address the issue as soon as possible. ================================================ FILE: bruno/API Keys/Create API Key.bru ================================================ meta { name: Create API Key type: http seq: 1 } put { url: http://localhost:3000/api/v1/api-key body: json auth: inherit } body:json { { "isRoot": true } } ================================================ FILE: bruno/API Keys/Delete API Key.bru ================================================ meta { name: Delete API Key type: http seq: 2 } delete { url: http://localhost:3000/api/v1/api-key/dm47aacqxxn3ubj body: none auth: inherit } ================================================ FILE: bruno/API Keys/List API Key Actions.bru ================================================ meta { name: List API Key Actions type: http seq: 6 } get { url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions body: none auth: inherit } ================================================ FILE: bruno/API Keys/List Org API Keys.bru ================================================ meta { name: List Org API Keys type: http seq: 4 } get { url: http://localhost:3000/api/v1/org/home-lab/api-keys body: none auth: inherit } ================================================ FILE: bruno/API Keys/List Root API Keys.bru ================================================ meta { name: List Root API Keys type: http seq: 3 } get { url: http://localhost:3000/api/v1/root/api-keys body: none auth: inherit } ================================================ FILE: bruno/API Keys/Set API Key Actions.bru ================================================ meta { name: Set API Key Actions type: http seq: 5 } post { url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/actions body: json auth: inherit } body:json { { "actionIds": ["listSites"] } } ================================================ FILE: bruno/API Keys/Set API Key Orgs.bru ================================================ meta { name: Set API Key Orgs type: http seq: 7 } post { url: http://localhost:3000/api/v1/api-key/ex0izu2c37fjz9x/orgs body: json auth: inherit } body:json { { "orgIds": ["home-lab"] } } ================================================ FILE: bruno/API Keys/folder.bru ================================================ meta { name: API Keys } ================================================ FILE: bruno/Auth/2fa-disable.bru ================================================ meta { name: 2fa-disable type: http seq: 6 } post { url: http://localhost:3000/api/v1/auth/2fa/disable body: json auth: none } body:json { { "password": "aaaaa-1A", "code": "377289" } } ================================================ FILE: bruno/Auth/2fa-enable.bru ================================================ meta { name: 2fa-enable type: http seq: 4 } post { url: http://localhost:3000/api/v1/auth/2fa/enable body: json auth: none } body:json { { "code": "374138" } } ================================================ FILE: bruno/Auth/2fa-request.bru ================================================ meta { name: 2fa-request type: http seq: 5 } post { url: http://localhost:3000/api/v1/auth/2fa/request body: json auth: none } body:json { { "password": "aaaaa-1A" } } ================================================ FILE: bruno/Auth/change-password.bru ================================================ meta { name: change-password type: http seq: 9 } post { url: http://localhost:3000/api/v1/auth/change-password body: json auth: none } body:json { { "oldPassword": "", "newPassword": "" } } ================================================ FILE: bruno/Auth/login.bru ================================================ meta { name: login type: http seq: 1 } post { url: http://localhost:3000/api/v1/auth/login body: json auth: none } body:json { { "email": "admin@fosrl.io", "password": "Password123!" } } ================================================ FILE: bruno/Auth/logout.bru ================================================ meta { name: logout type: http seq: 3 } post { url: http://localhost:4000/api/v1/auth/logout body: none auth: none } ================================================ FILE: bruno/Auth/reset-password-request.bru ================================================ meta { name: reset-password-request type: http seq: 10 } post { url: http://localhost:3000/api/v1/auth/reset-password/request body: json auth: none } body:json { { "email": "milo@pangolin.net" } } ================================================ FILE: bruno/Auth/reset-password.bru ================================================ meta { name: reset-password type: http seq: 11 } post { url: http://localhost:3000/api/v1/auth/reset-password body: json auth: none } body:json { { "token": "3uhsbom72dwdhboctwrtntyd6jrlg4jtf5oaxy4k", "newPassword": "aaaaa-1A", "code": "6irqCGR3" } } ================================================ FILE: bruno/Auth/signup.bru ================================================ meta { name: signup type: http seq: 2 } put { url: http://localhost:3000/api/v1/auth/signup body: json auth: none } body:json { { "email": "numbat@pangolin.net", "password": "Password123!" } } ================================================ FILE: bruno/Auth/verify-email-request.bru ================================================ meta { name: verify-email-request type: http seq: 8 } post { url: http://localhost:3000/api/v1/auth/verify-email/request body: none auth: none } ================================================ FILE: bruno/Auth/verify-email.bru ================================================ meta { name: verify-email type: http seq: 7 } post { url: http://localhost:3000/api/v1/auth/verify-email body: json auth: none } body:json { { "code": "50317187" } } ================================================ FILE: bruno/Auth/verify-user.bru ================================================ meta { name: verify-user type: http seq: 4 } get { url: http://localhost:3001/api/v1/badger/verify-user?sessionId=mb52273jkb6t3oys2bx6ur5x7rcrkl26c7warg3e body: none auth: none } params:query { sessionId: mb52273jkb6t3oys2bx6ur5x7rcrkl26c7warg3e } ================================================ FILE: bruno/Clients/createClient.bru ================================================ meta { name: createClient type: http seq: 1 } put { url: http://localhost:3000/api/v1/site/1/client body: json auth: none } body:json { { "siteId": 1, "name": "test", "type": "olm", "subnet": "100.90.129.4/30", "olmId": "029yzunhx6nh3y5", "secret": "l0ymp075y3d4rccb25l6sqpgar52k09etunui970qq5gj7x6" } } ================================================ FILE: bruno/Clients/pickClientDefaults.bru ================================================ meta { name: pickClientDefaults type: http seq: 2 } get { url: http://localhost:3000/api/v1/site/1/pick-client-defaults body: none auth: none } ================================================ FILE: bruno/IDP/Create OIDC Provider.bru ================================================ meta { name: Create OIDC Provider type: http seq: 1 } put { url: http://localhost:3000/api/v1/org/home-lab/idp/oidc body: json auth: inherit } body:json { { "clientId": "JJoSvHCZcxnXT2sn6CObj6a21MuKNRXs3kN5wbys", "clientSecret": "2SlGL2wOGgMEWLI9yUuMAeFxre7qSNJVnXMzyepdNzH1qlxYnC4lKhhQ6a157YQEkYH3vm40KK4RCqbYiF8QIweuPGagPX3oGxEj2exwutoXFfOhtq4hHybQKoFq01Z3", "authUrl": "http://localhost:9000/application/o/authorize/", "tokenUrl": "http://localhost:9000/application/o/token/", "scopes": ["email", "openid", "profile"], "userIdentifier": "email" } } ================================================ FILE: bruno/IDP/Generate OIDC URL.bru ================================================ meta { name: Generate OIDC URL type: http seq: 2 } get { url: http://localhost:3000/api/v1 body: none auth: inherit } ================================================ FILE: bruno/IDP/folder.bru ================================================ meta { name: IDP } ================================================ FILE: bruno/Internal/Traefik Config.bru ================================================ meta { name: Traefik Config type: http seq: 1 } get { url: http://localhost:3001/api/v1/traefik-config body: none auth: inherit } ================================================ FILE: bruno/Internal/folder.bru ================================================ meta { name: Internal } ================================================ FILE: bruno/Newt/Create Newt.bru ================================================ meta { name: Create Newt type: http seq: 2 } get { url: http://localhost:3000/api/v1/newt body: none auth: none } ================================================ FILE: bruno/Newt/Get Token.bru ================================================ meta { name: Get Token type: http seq: 1 } get { url: http://localhost:3000/api/v1/auth/newt/get-token body: json auth: none } body:json { { "newtId": "o0d4rdxq3stnz7b", "secret": "sy7l09fnaesd03iwrfp9m3qf0ryn19g0zf3dqieaazb4k7vk" } } ================================================ FILE: bruno/Olm/createOlm.bru ================================================ meta { name: createOlm type: http seq: 1 } put { url: http://localhost:3000/api/v1/olm body: none auth: inherit } settings { encodeUrl: true } ================================================ FILE: bruno/Olm/folder.bru ================================================ meta { name: Olm seq: 15 } auth { mode: inherit } ================================================ FILE: bruno/Orgs/Check Id.bru ================================================ meta { name: Check Id type: http seq: 2 } get { url: http://localhost:3000/api/v1/org/checkId body: none auth: none } ================================================ FILE: bruno/Orgs/listOrgs.bru ================================================ meta { name: listOrgs type: http seq: 1 } get { url: body: none auth: none } ================================================ FILE: bruno/Remote Exit Node/createRemoteExitNode.bru ================================================ meta { name: createRemoteExitNode type: http seq: 1 } put { url: http://localhost:4000/api/v1/org/org_i21aifypnlyxur2/remote-exit-node body: none auth: none } ================================================ FILE: bruno/Resources/listResourcesByOrg.bru ================================================ meta { name: listResourcesByOrg type: http seq: 1 } get { url: body: none auth: none } ================================================ FILE: bruno/Resources/listResourcesBySite.bru ================================================ meta { name: listResourcesBySite type: http seq: 2 } get { url: http://localhost:3000/api/v1/site/1/resources?limit=10&offset=0 body: none auth: none } params:query { limit: 10 offset: 0 } ================================================ FILE: bruno/Sites/Get Site.bru ================================================ meta { name: Get Site type: http seq: 2 } get { url: http://localhost:3000/api/v1/org/test/sites/mexican-mole-lizard-windy body: none auth: none } ================================================ FILE: bruno/Sites/listSites.bru ================================================ meta { name: listSites type: http seq: 1 } get { url: body: none auth: none } ================================================ FILE: bruno/Targets/listTargets.bru ================================================ meta { name: listTargets type: http seq: 1 } get { url: http://localhost:3000/api/v1/resource/web.main.localhost/targets?limit=10&offset=0 body: none auth: none } params:query { limit: 10 offset: 0 } ================================================ FILE: bruno/Test.bru ================================================ meta { name: Test type: http seq: 2 } get { url: http://localhost:3000/api/v1 body: none auth: inherit } ================================================ FILE: bruno/Traefik/traefik-config.bru ================================================ meta { name: traefik-config type: http seq: 1 } get { url: http://localhost:3001/api/v1/traefik-config body: none auth: none } ================================================ FILE: bruno/Users/adminListUsers.bru ================================================ meta { name: adminListUsers type: http seq: 2 } get { url: http://localhost:3000/api/v1/users body: none auth: none } ================================================ FILE: bruno/Users/adminRemoveUser.bru ================================================ meta { name: adminRemoveUser type: http seq: 3 } delete { url: http://localhost:3000/api/v1/user/ky5r7ivqs8wc7u4 body: none auth: none } ================================================ FILE: bruno/Users/getUser.bru ================================================ meta { name: getUser type: http seq: 1 } get { url: body: none auth: none } ================================================ FILE: bruno/bruno.json ================================================ { "version": "1", "name": "Pangolin", "type": "collection", "ignore": [ "node_modules", ".git" ], "presets": { "requestType": "http", "requestUrl": "http://localhost:3000/api/v1" } } ================================================ FILE: cli/commands/clearExitNodes.ts ================================================ import { CommandModule } from "yargs"; import { db, exitNodes } from "@server/db"; import { eq } from "drizzle-orm"; type ClearExitNodesArgs = { }; export const clearExitNodes: CommandModule< {}, ClearExitNodesArgs > = { command: "clear-exit-nodes", describe: "Clear all exit nodes from the database", // no args builder: (yargs) => { return yargs; }, handler: async (argv: {}) => { try { console.log(`Clearing all exit nodes from the database`); // Delete all exit nodes const deletedCount = await db .delete(exitNodes) .where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)) .returning();; // delete all console.log(`Deleted ${deletedCount.length} exit node(s) from the database`); process.exit(0); } catch (error) { console.error("Error:", error); process.exit(1); } } }; ================================================ FILE: cli/commands/clearLicenseKeys.ts ================================================ import { CommandModule } from "yargs"; import { db, licenseKey } from "@server/db"; import { eq } from "drizzle-orm"; type ClearLicenseKeysArgs = { }; export const clearLicenseKeys: CommandModule< {}, ClearLicenseKeysArgs > = { command: "clear-license-keys", describe: "Clear all license keys from the database", // no args builder: (yargs) => { return yargs; }, handler: async (argv: {}) => { try { console.log(`Clearing all license keys from the database`); // Delete all license keys const deletedCount = await db .delete(licenseKey) .where(eq(licenseKey.licenseKeyId, licenseKey.licenseKeyId)) .returning();; // delete all console.log(`Deleted ${deletedCount.length} license key(s) from the database`); process.exit(0); } catch (error) { console.error("Error:", error); process.exit(1); } } }; ================================================ FILE: cli/commands/deleteClient.ts ================================================ import { CommandModule } from "yargs"; import { db, clients, olms, currentFingerprint, userClients, approvals } from "@server/db"; import { eq, and, inArray } from "drizzle-orm"; type DeleteClientArgs = { orgId: string; niceId: string; }; export const deleteClient: CommandModule<{}, DeleteClientArgs> = { command: "delete-client", describe: "Delete a client and all associated data (OLMs, current fingerprint, userClients, approvals). Snapshots are preserved.", builder: (yargs) => { return yargs .option("orgId", { type: "string", demandOption: true, describe: "The organization ID" }) .option("niceId", { type: "string", demandOption: true, describe: "The client niceId (identifier)" }); }, handler: async (argv: { orgId: string; niceId: string }) => { try { const { orgId, niceId } = argv; console.log( `Deleting client with orgId: ${orgId}, niceId: ${niceId}...` ); // Find the client const [client] = await db .select() .from(clients) .where(and(eq(clients.orgId, orgId), eq(clients.niceId, niceId))) .limit(1); if (!client) { console.error( `Error: Client with orgId "${orgId}" and niceId "${niceId}" not found.` ); process.exit(1); } const clientId = client.clientId; console.log(`Found client with clientId: ${clientId}`); // Find all OLMs associated with this client const associatedOlms = await db .select() .from(olms) .where(eq(olms.clientId, clientId)); console.log(`Found ${associatedOlms.length} OLM(s) associated with this client`); // Delete in a transaction to ensure atomicity await db.transaction(async (trx) => { // Delete currentFingerprint entries for the associated OLMs // Note: We delete these explicitly before deleting OLMs to ensure // we have control, even though cascade would handle it let fingerprintCount = 0; if (associatedOlms.length > 0) { const olmIds = associatedOlms.map((olm) => olm.olmId); const deletedFingerprints = await trx .delete(currentFingerprint) .where(inArray(currentFingerprint.olmId, olmIds)) .returning(); fingerprintCount = deletedFingerprints.length; } console.log(`Deleted ${fingerprintCount} current fingerprint(s)`); // Delete OLMs // Note: OLMs have onDelete: "set null" for clientId, so we need to delete them explicitly const deletedOlms = await trx .delete(olms) .where(eq(olms.clientId, clientId)) .returning(); console.log(`Deleted ${deletedOlms.length} OLM(s)`); // Delete approvals // Note: Approvals have onDelete: "cascade" but we delete explicitly for clarity const deletedApprovals = await trx .delete(approvals) .where(eq(approvals.clientId, clientId)) .returning(); console.log(`Deleted ${deletedApprovals.length} approval(s)`); // Delete userClients // Note: userClients have onDelete: "cascade" but we delete explicitly for clarity const deletedUserClients = await trx .delete(userClients) .where(eq(userClients.clientId, clientId)) .returning(); console.log(`Deleted ${deletedUserClients.length} userClient association(s)`); // Finally, delete the client itself const deletedClients = await trx .delete(clients) .where(eq(clients.clientId, clientId)) .returning(); console.log(`Deleted client: ${deletedClients[0]?.name || niceId}`); }); console.log("\nClient deletion completed successfully!"); console.log("\nSummary:"); console.log(` - Client: ${niceId} (clientId: ${clientId})`); console.log(` - Olm(s): ${associatedOlms.length}`); console.log(` - Current fingerprints: deleted`); console.log(` - Approvals: deleted`); console.log(` - UserClients: deleted`); console.log(` - Snapshots: preserved (not deleted)`); process.exit(0); } catch (error) { console.error("Error deleting client:", error); process.exit(1); } } }; ================================================ FILE: cli/commands/generateOrgCaKeys.ts ================================================ 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/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); } } }; ================================================ FILE: cli/commands/resetUserSecurityKeys.ts ================================================ import { CommandModule } from "yargs"; import { db, users, securityKeys } from "@server/db"; import { eq } from "drizzle-orm"; type ResetUserSecurityKeysArgs = { email: string; }; export const resetUserSecurityKeys: CommandModule< {}, ResetUserSecurityKeysArgs > = { command: "reset-user-security-keys", describe: "Reset a user's security keys (passkeys) by deleting all their webauthn credentials", builder: (yargs) => { return yargs.option("email", { type: "string", demandOption: true, describe: "User email address" }); }, handler: async (argv: { email: string }) => { try { const { email } = argv; console.log(`Looking for user with email: ${email}`); // Find the user by email const [user] = await db .select() .from(users) .where(eq(users.email, email)) .limit(1); if (!user) { console.error(`User with email '${email}' not found`); process.exit(1); } console.log(`Found user: ${user.email} (ID: ${user.userId})`); // Check if user has any security keys const userSecurityKeys = await db .select() .from(securityKeys) .where(eq(securityKeys.userId, user.userId)); if (userSecurityKeys.length === 0) { console.log(`User '${email}' has no security keys to reset`); process.exit(0); } console.log( `Found ${userSecurityKeys.length} security key(s) for user '${email}'` ); // Delete all security keys for the user await db .delete(securityKeys) .where(eq(securityKeys.userId, user.userId)); console.log(`Successfully reset security keys for user '${email}'`); console.log(`Deleted ${userSecurityKeys.length} security key(s)`); process.exit(0); } catch (error) { console.error("Error:", error); process.exit(1); } } }; ================================================ FILE: cli/commands/rotateServerSecret.ts ================================================ import { CommandModule } from "yargs"; import { db, idpOidcConfig, licenseKey } from "@server/db"; import { encrypt, decrypt } from "@server/lib/crypto"; import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { eq } from "drizzle-orm"; import fs from "fs"; import yaml from "js-yaml"; type RotateServerSecretArgs = { "old-secret": string; "new-secret": string; force?: boolean; }; export const rotateServerSecret: CommandModule< {}, RotateServerSecretArgs > = { command: "rotate-server-secret", describe: "Rotate the server secret by decrypting all encrypted values with the old secret and re-encrypting with a new secret", builder: (yargs) => { return yargs .option("old-secret", { type: "string", demandOption: true, describe: "The current server secret (for verification)" }) .option("new-secret", { type: "string", demandOption: true, describe: "The new server secret to use" }) .option("force", { type: "boolean", default: false, describe: "Force rotation even if the old secret doesn't match the config file. " + "Use this if you know the old secret is correct but the config file is out of sync. " + "WARNING: This will attempt to decrypt all values with the provided old secret. " + "If the old secret is incorrect, the rotation will fail or corrupt data." }); }, handler: async (argv: { "old-secret": string; "new-secret": string; force?: boolean; }) => { try { // Determine which config file exists const configPath = fs.existsSync(configFilePath1) ? configFilePath1 : fs.existsSync(configFilePath2) ? configFilePath2 : null; if (!configPath) { console.error( "Error: Config file not found. Expected config.yml or config.yaml in the config directory." ); process.exit(1); } // Read current config const configContent = fs.readFileSync(configPath, "utf8"); const config = yaml.load(configContent) as any; if (!config?.server?.secret) { console.error( "Error: No server secret found in config file. Cannot rotate." ); process.exit(1); } const configSecret = config.server.secret; const oldSecret = argv["old-secret"]; const newSecret = argv["new-secret"]; const force = argv.force || false; // Verify that the provided old secret matches the one in config if (configSecret !== oldSecret) { if (!force) { console.error( "Error: The provided old secret does not match the secret in the config file." ); console.error( "\nIf you are certain the old secret is correct and the config file is out of sync," ); console.error( "you can use the --force flag to bypass this check." ); console.error( "\nWARNING: Using --force with an incorrect old secret will cause the rotation to fail" ); console.error( "or corrupt encrypted data. Only use --force if you are absolutely certain." ); process.exit(1); } else { console.warn( "\nWARNING: Using --force flag. Bypassing old secret verification." ); console.warn( "The provided old secret does not match the config file, but proceeding anyway." ); console.warn( "If the old secret is incorrect, this operation will fail or corrupt data.\n" ); } } // Validate new secret if (newSecret.length < 8) { console.error( "Error: New secret must be at least 8 characters long" ); process.exit(1); } if (oldSecret === newSecret) { console.error("Error: New secret must be different from old secret"); process.exit(1); } console.log("Starting server secret rotation..."); console.log("This will decrypt and re-encrypt all encrypted values in the database."); // Read all data first console.log("\nReading encrypted data from database..."); const idpConfigs = await db.select().from(idpOidcConfig); const licenseKeys = await db.select().from(licenseKey); console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`); console.log(`Found ${licenseKeys.length} license key(s)`); // Prepare all decrypted and re-encrypted values console.log("\nDecrypting and re-encrypting values..."); type IdpUpdate = { idpOauthConfigId: number; encryptedClientId: string; encryptedClientSecret: string; }; type LicenseKeyUpdate = { oldLicenseKeyId: string; newLicenseKeyId: string; encryptedToken: string; encryptedInstanceId: string; }; const idpUpdates: IdpUpdate[] = []; const licenseKeyUpdates: LicenseKeyUpdate[] = []; // Process idpOidcConfig entries for (const idpConfig of idpConfigs) { try { // Decrypt with old secret const decryptedClientId = decrypt(idpConfig.clientId, oldSecret); const decryptedClientSecret = decrypt( idpConfig.clientSecret, oldSecret ); // Re-encrypt with new secret const encryptedClientId = encrypt(decryptedClientId, newSecret); const encryptedClientSecret = encrypt( decryptedClientSecret, newSecret ); idpUpdates.push({ idpOauthConfigId: idpConfig.idpOauthConfigId, encryptedClientId, encryptedClientSecret }); } catch (error) { console.error( `Error processing IdP config ${idpConfig.idpOauthConfigId}:`, error ); throw error; } } // Process licenseKey entries for (const key of licenseKeys) { try { // Decrypt with old secret const decryptedLicenseKeyId = decrypt(key.licenseKeyId, oldSecret); const decryptedToken = decrypt(key.token, oldSecret); const decryptedInstanceId = decrypt(key.instanceId, oldSecret); // Re-encrypt with new secret const encryptedLicenseKeyId = encrypt( decryptedLicenseKeyId, newSecret ); const encryptedToken = encrypt(decryptedToken, newSecret); const encryptedInstanceId = encrypt( decryptedInstanceId, newSecret ); licenseKeyUpdates.push({ oldLicenseKeyId: key.licenseKeyId, newLicenseKeyId: encryptedLicenseKeyId, encryptedToken, encryptedInstanceId }); } catch (error) { console.error( `Error processing license key ${key.licenseKeyId}:`, error ); throw error; } } // Perform all database updates in a single transaction console.log("\nUpdating database in transaction..."); await db.transaction(async (trx) => { // Update idpOidcConfig entries for (const update of idpUpdates) { await trx .update(idpOidcConfig) .set({ clientId: update.encryptedClientId, clientSecret: update.encryptedClientSecret }) .where( eq( idpOidcConfig.idpOauthConfigId, update.idpOauthConfigId ) ); } // Update licenseKey entries (delete old, insert new) for (const update of licenseKeyUpdates) { // Delete old entry await trx .delete(licenseKey) .where(eq(licenseKey.licenseKeyId, update.oldLicenseKeyId)); // Insert new entry with re-encrypted values await trx.insert(licenseKey).values({ licenseKeyId: update.newLicenseKeyId, token: update.encryptedToken, instanceId: update.encryptedInstanceId }); } }); console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`); console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`); // Update config file with new secret console.log("\nUpdating config file..."); config.server.secret = newSecret; const newConfigContent = yaml.dump(config, { indent: 2, lineWidth: -1 }); fs.writeFileSync(configPath, newConfigContent, "utf8"); console.log(`Updated config file: ${configPath}`); console.log("\nServer secret rotation completed successfully!"); console.log(`\nSummary:`); console.log(` - OIDC IdP configurations: ${idpUpdates.length}`); console.log(` - License keys: ${licenseKeyUpdates.length}`); console.log( `\n IMPORTANT: Restart the server for the new secret to take effect.` ); process.exit(0); } catch (error) { console.error("Error rotating server secret:", error); process.exit(1); } } }; ================================================ FILE: cli/commands/setAdminCredentials.ts ================================================ import { CommandModule } from "yargs"; import { hashPassword, verifyPassword } from "@server/auth/password"; import { db, resourceSessions, sessions } from "@server/db"; import { users } from "@server/db"; import { eq, inArray } from "drizzle-orm"; import moment from "moment"; import { fromError } from "zod-validation-error"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; import { generateRandomString, RandomReader } from "@oslojs/crypto/random"; type SetAdminCredentialsArgs = { email: string; password: string; }; export const setAdminCredentials: CommandModule<{}, SetAdminCredentialsArgs> = { command: "set-admin-credentials", describe: "Set the server admin credentials", builder: (yargs) => { return yargs .option("email", { type: "string", demandOption: true, describe: "Admin email address" }) .option("password", { type: "string", demandOption: true, describe: "Admin password" }); }, handler: async (argv: { email: string; password: string }) => { try { const { password } = argv; let { email } = argv; email = email.trim().toLowerCase(); const parsed = passwordSchema.safeParse(password); if (!parsed.success) { throw Error( `Invalid server admin password: ${fromError(parsed.error).toString()}` ); } const passwordHash = await hashPassword(password); await db.transaction(async (trx) => { try { const [existing] = await trx .select() .from(users) .where(eq(users.serverAdmin, true)); if (existing) { const passwordChanged = !(await verifyPassword( password, existing.passwordHash! )); if (passwordChanged) { await trx .update(users) .set({ passwordHash }) .where(eq(users.userId, existing.userId)); await invalidateAllSessions(existing.userId); console.log("Server admin password updated"); } if (existing.email !== email) { await trx .update(users) .set({ email, username: email }) .where(eq(users.userId, existing.userId)); console.log("Server admin email updated"); } } else { const userId = generateId(15); await trx.update(users).set({ serverAdmin: false }); await db.insert(users).values({ userId: userId, email: email, type: UserType.Internal, username: email, passwordHash, dateCreated: moment().toISOString(), serverAdmin: true, emailVerified: true, lastPasswordChange: new Date().getTime() }); console.log("Server admin created"); } } catch (e) { console.error("Failed to set admin credentials", e); trx.rollback(); throw e; } }); console.log("Admin credentials updated successfully"); process.exit(0); } catch (error) { console.error("Error:", error); process.exit(1); } } }; export async function invalidateAllSessions(userId: string): Promise { try { await db.transaction(async (trx) => { const userSessions = await trx .select() .from(sessions) .where(eq(sessions.userId, userId)); await trx.delete(resourceSessions).where( inArray( resourceSessions.userSessionId, userSessions.map((s) => s.sessionId) ) ); await trx.delete(sessions).where(eq(sessions.userId, userId)); }); } catch (e) { console.log("Failed to all invalidate user sessions", e); } } const random: RandomReader = { read(bytes: Uint8Array): void { crypto.getRandomValues(bytes); } }; export function generateId(length: number): string { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; return generateRandomString(random, alphabet, length); } ================================================ FILE: cli/index.ts ================================================ #!/usr/bin/env node import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { setAdminCredentials } from "@cli/commands/setAdminCredentials"; import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys"; 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") .command(setAdminCredentials) .command(resetUserSecurityKeys) .command(clearExitNodes) .command(rotateServerSecret) .command(clearLicenseKeys) .command(deleteClient) .command(generateOrgCaKeys) .demandCommand() .help().argv; ================================================ FILE: cli/wrapper.sh ================================================ #!/bin/sh cd /app/ ./dist/cli.mjs "$@" ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": true, "tsx": true, "tailwind": { "config": "tailwind.config.ts", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" } } ================================================ FILE: config/.gitkeep ================================================ ================================================ FILE: config/config.example.yml ================================================ # To see all available options, please visit the docs: # https://docs.pangolin.net/ gerbil: start_port: 51820 base_endpoint: "{{.DashboardDomain}}" app: dashboard_url: "https://{{.DashboardDomain}}" log_level: "info" telemetry: anonymous_usage: true domains: domain1: base_domain: "{{.BaseDomain}}" server: secret: "{{.Secret}}" cors: origins: ["https://{{.DashboardDomain}}"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] allowed_headers: ["X-CSRF-Token", "Content-Type"] credentials: false flags: require_email_verification: false disable_signup_without_invite: true disable_user_create_org: false allow_raw_resources: true ================================================ FILE: config/db/.gitkeep ================================================ ================================================ FILE: config/logs/.gitkeep ================================================ ================================================ FILE: config/traefik/dynamic_config.yml ================================================ http: middlewares: badger: plugin: badger: disableForwardAuth: true redirect-to-https: redirectScheme: scheme: https routers: # HTTP to HTTPS redirect router main-app-router-redirect: rule: "Host(`{{.DashboardDomain}}`)" service: next-service entryPoints: - web middlewares: - redirect-to-https - badger # Next.js router (handles everything except API and WebSocket paths) next-router: rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" service: next-service entryPoints: - websecure middlewares: - badger tls: certResolver: letsencrypt # API router (handles /api/v1 paths) api-router: rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" service: api-service entryPoints: - websecure middlewares: - badger tls: certResolver: letsencrypt services: next-service: loadBalancer: servers: - url: "http://pangolin:3002" # Next.js server api-service: loadBalancer: servers: - url: "http://pangolin:3000" # API/WebSocket server tcp: serversTransports: pp-transport-v1: proxyProtocol: version: 1 pp-transport-v2: proxyProtocol: version: 2 ================================================ FILE: config/traefik/traefik_config.yml ================================================ api: insecure: true dashboard: true providers: http: endpoint: "http://pangolin:3001/api/v1/traefik-config" pollInterval: "5s" file: filename: "/etc/traefik/dynamic_config.yml" experimental: plugins: badger: moduleName: "github.com/fosrl/badger" version: "{{.BadgerVersion}}" log: level: "INFO" format: "common" maxSize: 100 maxBackups: 3 maxAge: 3 compress: true certificatesResolvers: letsencrypt: acme: httpChallenge: entryPoint: web email: "{{.LetsEncryptEmail}}" storage: "/letsencrypt/acme.json" caServer: "https://acme-v02.api.letsencrypt.org/directory" entryPoints: web: address: ":80" websecure: address: ":443" transport: respondingTimeouts: readTimeout: "30m" http: tls: certResolver: "letsencrypt" encodedCharacters: allowEncodedSlash: true allowEncodedQuestionMark: true serversTransport: insecureSkipVerify: true ping: entryPoint: "web" ================================================ FILE: crowdin.yml ================================================ files: - source: /messages/en-US.json translation: /messages/%locale%.json ================================================ FILE: docker-compose.drizzle.yml ================================================ services: drizzle-gateway: image: ghcr.io/drizzle-team/gateway:latest ports: - "4984:4983" depends_on: - db environment: - STORE_PATH=/app - DATABASE_URL=postgresql://postgres:password@db:5432/postgres volumes: - drizzle-gateway-data:/app volumes: drizzle-gateway-data: ================================================ FILE: docker-compose.example.yml ================================================ name: pangolin services: pangolin: image: fosrl/pangolin:latest container_name: pangolin restart: unless-stopped deploy: resources: limits: memory: 1g reservations: memory: 256m volumes: - ./config:/app/config healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"] interval: "3s" timeout: "3s" retries: 15 gerbil: image: fosrl/gerbil:latest container_name: gerbil restart: unless-stopped depends_on: pangolin: condition: service_healthy command: - --reachableAt=http://gerbil:3004 - --generateAndSaveKeyTo=/var/config/key - --remoteConfig=http://pangolin:3001/api/v1/ volumes: - ./config/:/var/config cap_add: - NET_ADMIN - SYS_MODULE ports: - 51820:51820/udp - 21820:21820/udp - 443:443 # Port for traefik because of the network_mode - 80:80 # Port for traefik because of the network_mode traefik: image: traefik:v3.6 container_name: traefik restart: unless-stopped network_mode: service:gerbil # Ports appear on the gerbil service depends_on: pangolin: condition: service_healthy command: - --configFile=/etc/traefik/traefik_config.yml volumes: - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates networks: default: driver: bridge name: pangolin enable_ipv6: true ================================================ FILE: docker-compose.pgr.yml ================================================ services: # PostgreSQL Service db: image: postgres:17 # Use the PostgreSQL 17 image container_name: dev_postgres # Name your PostgreSQL container environment: POSTGRES_DB: postgres # Default database name POSTGRES_USER: postgres # Default user POSTGRES_PASSWORD: password # Default password (change for production!) # volumes: # - ./config/postgres:/var/lib/postgresql/data ports: - "5432:5432" # Map host port 5432 to container port 5432 restart: no redis: image: redis:latest # Use the latest Redis image container_name: dev_redis # Name your Redis container ports: - "6379:6379" # Map host port 6379 to container port 6379 restart: no ================================================ FILE: docker-compose.yml ================================================ services: # Development application service app: build: context: . dockerfile: Dockerfile.dev container_name: dev_pangolin ports: - "3000:3000" - "3001:3001" - "3002:3002" - "3003:3003" environment: - NODE_ENV=development - ENVIRONMENT=dev volumes: # Mount source code for hot reload - ./src:/app/src - ./server:/app/server - ./public:/app/public - ./messages:/app/messages - ./components.json:/app/components.json - ./next.config.mjs:/app/next.config.mjs - ./tsconfig.json:/app/tsconfig.json - ./tailwind.config.js:/app/tailwind.config.js - ./postcss.config.mjs:/app/postcss.config.mjs - ./eslint.config.js:/app/eslint.config.js - ./config:/app/config restart: no ================================================ FILE: drizzle.pg.config.ts ================================================ import { defineConfig } from "drizzle-kit"; import path from "path"; const schema = [path.join("server", "db", "pg", "schema")]; export default defineConfig({ dialect: "postgresql", schema: schema, out: path.join("server", "migrations"), verbose: true, dbCredentials: { url: process.env.DATABASE_URL as string } }); ================================================ FILE: drizzle.sqlite.config.ts ================================================ import { APP_PATH } from "@server/lib/consts"; import { defineConfig } from "drizzle-kit"; import path from "path"; const schema = [path.join("server", "db", "sqlite", "schema")]; export default defineConfig({ dialect: "sqlite", schema: schema, out: path.join("server", "migrations"), verbose: true, dbCredentials: { url: path.join(APP_PATH, "db", "db.sqlite") } }); ================================================ FILE: esbuild.mjs ================================================ import esbuild from "esbuild"; import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { nodeExternalsPlugin } from "esbuild-node-externals"; import path from "path"; import fs from "fs"; // import { glob } from "glob"; // Read default build type from server/build.ts let build = "oss"; const buildFile = fs.readFileSync(path.resolve("server/build.ts"), "utf8"); const m = buildFile.match(/export\s+const\s+build\s*=\s*["'](oss|saas|enterprise)["']/); if (m) build = m[1]; const banner = ` // patch __dirname // import { fileURLToPath } from "url"; // import path from "path"; // const __filename = fileURLToPath(import.meta.url); // const __dirname = path.dirname(__filename); // allow top level await import { createRequire as topLevelCreateRequire } from "module"; const require = topLevelCreateRequire(import.meta.url); `; const argv = yargs(hideBin(process.argv)) .usage("Usage: $0 -entry [string] -out [string] -build [string]") .option("entry", { alias: "e", describe: "Entry point file", type: "string", demandOption: true }) .option("out", { alias: "o", describe: "Output file path", type: "string", demandOption: true }) .option("build", { alias: "b", describe: "Build type (oss, saas, enterprise)", type: "string", choices: ["oss", "saas", "enterprise"], default: build }) .help() .alias("help", "h").argv; // generate a list of all package.json files in the monorepo function getPackagePaths() { // const packagePaths = []; // const packageGlob = "package.json"; // const packageJsonFiles = glob.sync(packageGlob); // for (const packageJsonFile of packageJsonFiles) { // packagePaths.push(path.dirname(packageJsonFile) + "/package.json"); // } // return packagePaths; return ["package.json"]; } // Plugin to guard against bad imports from #private function privateImportGuardPlugin() { return { name: "private-import-guard", setup(build) { const violations = []; build.onResolve({ filter: /^#private\// }, (args) => { const importingFile = args.importer; // Check if the importing file is NOT in server/private const normalizedImporter = path.normalize(importingFile); const isInServerPrivate = normalizedImporter.includes( path.normalize("server/private") ); if (!isInServerPrivate) { const violation = { file: importingFile, importPath: args.path, resolveDir: args.resolveDir }; violations.push(violation); console.log(`PRIVATE IMPORT VIOLATION:`); console.log(` File: ${importingFile}`); console.log(` Import: ${args.path}`); console.log(` Resolve dir: ${args.resolveDir || "N/A"}`); console.log(""); } // Return null to let the default resolver handle it return null; }); build.onEnd((result) => { if (violations.length > 0) { console.log( `\nSUMMARY: Found ${violations.length} private import violation(s):` ); violations.forEach((v, i) => { console.log( ` ${i + 1}. ${path.relative(process.cwd(), v.file)} imports ${v.importPath}` ); }); console.log(""); result.errors.push({ text: `Private import violations detected: ${violations.length} violation(s) found`, location: null, notes: violations.map((v) => ({ text: `${path.relative(process.cwd(), v.file)} imports ${v.importPath}`, location: null })) }); } }); } }; } // Plugin to guard against bad imports from #private function dynamicImportGuardPlugin() { return { name: "dynamic-import-guard", setup(build) { const violations = []; build.onResolve({ filter: /^#dynamic\// }, (args) => { const importingFile = args.importer; // Check if the importing file is NOT in server/private const normalizedImporter = path.normalize(importingFile); const isInServerPrivate = normalizedImporter.includes( path.normalize("server/private") ); if (isInServerPrivate) { const violation = { file: importingFile, importPath: args.path, resolveDir: args.resolveDir }; violations.push(violation); console.log(`DYNAMIC IMPORT VIOLATION:`); console.log(` File: ${importingFile}`); console.log(` Import: ${args.path}`); console.log(` Resolve dir: ${args.resolveDir || "N/A"}`); console.log(""); } // Return null to let the default resolver handle it return null; }); build.onEnd((result) => { if (violations.length > 0) { console.log( `\nSUMMARY: Found ${violations.length} dynamic import violation(s):` ); violations.forEach((v, i) => { console.log( ` ${i + 1}. ${path.relative(process.cwd(), v.file)} imports ${v.importPath}` ); }); console.log(""); result.errors.push({ text: `Dynamic import violations detected: ${violations.length} violation(s) found`, location: null, notes: violations.map((v) => ({ text: `${path.relative(process.cwd(), v.file)} imports ${v.importPath}`, location: null })) }); } }); } }; } // Plugin to dynamically switch imports based on build type function dynamicImportSwitcherPlugin(buildValue) { return { name: "dynamic-import-switcher", setup(build) { const switches = []; build.onStart(() => { console.log( `Dynamic import switcher using build type: ${buildValue}` ); }); build.onResolve({ filter: /^#dynamic\// }, (args) => { // Extract the path after #dynamic/ const dynamicPath = args.path.replace(/^#dynamic\//, ""); // Determine the replacement based on build type let replacement; if (buildValue === "oss") { replacement = `#open/${dynamicPath}`; } else if ( buildValue === "saas" || buildValue === "enterprise" ) { replacement = `#closed/${dynamicPath}`; // We use #closed here so that the route guards dont complain after its been changed but this is the same as #private } else { console.warn( `Unknown build type '${buildValue}', defaulting to #open/` ); replacement = `#open/${dynamicPath}`; } const switchInfo = { file: args.importer, originalPath: args.path, replacementPath: replacement, buildType: buildValue }; switches.push(switchInfo); console.log(`DYNAMIC IMPORT SWITCH:`); console.log(` File: ${args.importer}`); console.log(` Original: ${args.path}`); console.log( ` Switched to: ${replacement} (build: ${buildValue})` ); console.log(""); // Rewrite the import path and let the normal resolution continue return build.resolve(replacement, { importer: args.importer, namespace: args.namespace, resolveDir: args.resolveDir, kind: args.kind }); }); build.onEnd((result) => { if (switches.length > 0) { console.log( `\nDYNAMIC IMPORT SUMMARY: Switched ${switches.length} import(s) for build type '${buildValue}':` ); switches.forEach((s, i) => { console.log( ` ${i + 1}. ${path.relative(process.cwd(), s.file)}` ); console.log( ` ${s.originalPath} → ${s.replacementPath}` ); }); console.log(""); } }); } }; } esbuild .build({ entryPoints: [argv.entry], bundle: true, outfile: argv.out, format: "esm", minify: false, banner: { js: banner }, platform: "node", external: ["body-parser"], plugins: [ privateImportGuardPlugin(), dynamicImportGuardPlugin(), dynamicImportSwitcherPlugin(argv.build), nodeExternalsPlugin({ packagePath: getPackagePaths() }) ], sourcemap: "inline", target: "node24" }) .then((result) => { // Check if there were any errors in the build result if (result.errors && result.errors.length > 0) { console.error( `Build failed with ${result.errors.length} error(s):` ); result.errors.forEach((error, i) => { console.error(`${i + 1}. ${error.text}`); if (error.notes) { error.notes.forEach((note) => { console.error(` - ${note.text}`); }); } }); // remove the output file if it was created if (fs.existsSync(argv.out)) { fs.unlinkSync(argv.out); } process.exit(1); } console.log("Build completed successfully"); }) .catch((error) => { console.error("Build failed:", error); process.exit(1); }); ================================================ FILE: eslint.config.js ================================================ import tseslint from "typescript-eslint"; export default tseslint.config({ files: ["**/*.{ts,tsx,js,jsx}"], languageOptions: { parser: tseslint.parser, parserOptions: { ecmaVersion: "latest", sourceType: "module", ecmaFeatures: { jsx: true } } }, rules: { semi: "error", "prefer-const": "warn" } }); ================================================ FILE: install/Makefile ================================================ all: go-build-release # Build with version injection via ldflags # Versions can be passed via: make go-build-release PANGOLIN_VERSION=x.x.x GERBIL_VERSION=x.x.x BADGER_VERSION=x.x.x # Or fetched automatically if not provided (requires curl and jq) PANGOLIN_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') GERBIL_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') BADGER_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') LDFLAGS = -X main.pangolinVersion=$(PANGOLIN_VERSION) \ -X main.gerbilVersion=$(GERBIL_VERSION) \ -X main.badgerVersion=$(BADGER_VERSION) go-build-release: @echo "Building with versions - Pangolin: $(PANGOLIN_VERSION), Gerbil: $(GERBIL_VERSION), Badger: $(BADGER_VERSION)" CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_amd64 CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_arm64 clean: rm -f bin/installer_linux_amd64 rm -f bin/installer_linux_arm64 .PHONY: all go-build-release clean ================================================ FILE: install/config/config.yml ================================================ # To see all available options, please visit the docs: # https://docs.pangolin.net/ gerbil: start_port: 51820 base_endpoint: "{{.DashboardDomain}}" app: dashboard_url: "https://{{.DashboardDomain}}" log_level: "info" telemetry: anonymous_usage: true domains: domain1: base_domain: "{{.BaseDomain}}" server: secret: "{{.Secret}}" cors: origins: ["https://{{.DashboardDomain}}"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] allowed_headers: ["X-CSRF-Token", "Content-Type"] credentials: false {{if .EnableGeoblocking}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}} {{if .EnableEmail}} email: smtp_host: "{{.EmailSMTPHost}}" smtp_port: {{.EmailSMTPPort}} smtp_user: "{{.EmailSMTPUser}}" smtp_pass: "{{.EmailSMTPPass}}" no_reply: "{{.EmailNoReply}}" {{end}} flags: require_email_verification: {{.EnableEmail}} disable_signup_without_invite: true disable_user_create_org: false allow_raw_resources: true ================================================ FILE: install/config/crowdsec/acquis.d/appsec.yaml ================================================ listen_addr: 0.0.0.0:7422 appsec_config: crowdsecurity/appsec-default name: myAppSecComponent source: appsec labels: type: appsec ================================================ FILE: install/config/crowdsec/acquis.d/traefik.yaml ================================================ poll_without_inotify: false filenames: - /var/log/traefik/*.log labels: type: traefik ================================================ FILE: install/config/crowdsec/docker-compose.yml ================================================ services: crowdsec: image: docker.io/crowdsecurity/crowdsec:latest container_name: crowdsec environment: GID: "1000" COLLECTIONS: crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules ENROLL_INSTANCE_NAME: "pangolin-crowdsec" PARSERS: crowdsecurity/whitelists ENROLL_TAGS: docker healthcheck: test: - CMD - cscli - lapi - status interval: 10s timeout: 5s retries: 3 start_period: 30s labels: - "traefik.enable=false" # Disable traefik for crowdsec volumes: # crowdsec container data - ./config/crowdsec:/etc/crowdsec # crowdsec config - ./config/crowdsec/db:/var/lib/crowdsec/data # crowdsec db # log bind mounts into crowdsec - ./config/traefik/logs:/var/log/traefik # traefik logs ports: - 6060:6060 # metrics endpoint for prometheus restart: unless-stopped command: -t # Add test config flag to verify configuration ================================================ FILE: install/config/crowdsec/dynamic_config.yml ================================================ http: middlewares: badger: plugin: badger: disableForwardAuth: true redirect-to-https: redirectScheme: scheme: https default-whitelist: # Whitelist middleware for internal IPs ipWhiteList: # Internal IP addresses sourceRange: # Internal IP addresses - "10.0.0.0/8" # Internal IP addresses - "192.168.0.0/16" # Internal IP addresses - "172.16.0.0/12" # Internal IP addresses # Basic security headers security-headers: headers: customResponseHeaders: # Custom response headers Server: "" # Remove server header X-Powered-By: "" # Remove powered by header X-Forwarded-Proto: "https" # Set forwarded proto to https sslProxyHeaders: # SSL proxy headers X-Forwarded-Proto: "https" # Set forwarded proto to https hostsProxyHeaders: # Hosts proxy headers - "X-Forwarded-Host" # Set forwarded host contentTypeNosniff: true # Prevent MIME sniffing customFrameOptionsValue: "SAMEORIGIN" # Set frame options referrerPolicy: "strict-origin-when-cross-origin" # Set referrer policy forceSTSHeader: true # Force STS header stsIncludeSubdomains: true # Include subdomains stsSeconds: 63072000 # STS seconds stsPreload: true # Preload STS # CrowdSec configuration with proper IP forwarding crowdsec: plugin: crowdsec: enabled: true # Enable CrowdSec plugin logLevel: INFO # Log level updateIntervalSeconds: 15 # Update interval updateMaxFailure: 0 # Update max failure defaultDecisionSeconds: 15 # Default decision seconds httpTimeoutSeconds: 10 # HTTP timeout crowdsecMode: live # CrowdSec mode crowdsecAppsecEnabled: true # Enable AppSec crowdsecAppsecHost: crowdsec:7422 # CrowdSec IP address which you noted down later crowdsecAppsecFailureBlock: true # Block on failure crowdsecAppsecUnreachableBlock: true # Block on unreachable crowdsecAppsecBodyLimit: 10485760 crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later crowdsecLapiHost: crowdsec:8080 # CrowdSec crowdsecLapiScheme: http # CrowdSec API scheme forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs - "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE) clientTrustedIPs: # Client trusted IPs (CHANGE MADE HERE) - "10.0.0.0/8" # Internal LAN IP addresses - "172.16.0.0/12" # Internal LAN IP addresses - "192.168.0.0/16" # Internal LAN IP addresses - "100.89.137.0/20" # Internal LAN IP addresses routers: # HTTP to HTTPS redirect router main-app-router-redirect: rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name service: next-service entryPoints: - web middlewares: - redirect-to-https - badger # Next.js router (handles everything except API and WebSocket paths) next-router: rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" # Dynamic Domain Name service: next-service entryPoints: - websecure middlewares: - security-headers # Add security headers middleware - badger tls: certResolver: letsencrypt # API router (handles /api/v1 paths) api-router: rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" # Dynamic Domain Name service: api-service entryPoints: - websecure middlewares: - security-headers # Add security headers middleware - badger tls: certResolver: letsencrypt # WebSocket router ws-router: rule: "Host(`{{.DashboardDomain}}`)" # Dynamic Domain Name service: api-service entryPoints: - websecure middlewares: - security-headers # Add security headers middleware - badger tls: certResolver: letsencrypt services: next-service: loadBalancer: servers: - url: "http://pangolin:3002" # Next.js server api-service: loadBalancer: servers: - url: "http://pangolin:3000" # API/WebSocket server tcp: serversTransports: pp-transport-v1: proxyProtocol: version: 1 pp-transport-v2: proxyProtocol: version: 2 ================================================ FILE: install/config/crowdsec/profiles.yaml ================================================ name: captcha_remediation filters: - Alert.Remediation == true && Alert.GetScope() == "Ip" && Alert.GetScenario() contains "http" decisions: - type: captcha duration: 4h on_success: break --- name: default_ip_remediation filters: - Alert.Remediation == true && Alert.GetScope() == "Ip" decisions: - type: ban duration: 4h on_success: break --- name: default_range_remediation filters: - Alert.Remediation == true && Alert.GetScope() == "Range" decisions: - type: ban duration: 4h on_success: break ================================================ FILE: install/config/crowdsec/traefik_config.yml ================================================ api: insecure: true dashboard: true providers: http: endpoint: "http://pangolin:3001/api/v1/traefik-config" pollInterval: "5s" file: filename: "/etc/traefik/dynamic_config.yml" experimental: plugins: badger: moduleName: "github.com/fosrl/badger" version: "{{.BadgerVersion}}" crowdsec: # CrowdSec plugin configuration added moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin" version: "v1.4.4" log: level: "INFO" format: "json" # Log format changed to json for better parsing maxSize: 100 maxBackups: 3 maxAge: 3 compress: true accessLog: # We enable access logs as json filePath: "/var/log/traefik/access.log" format: json filters: statusCodes: - "200-299" # Success codes - "400-499" # Client errors - "500-599" # Server errors retryAttempts: true minDuration: "100ms" # Increased to focus on slower requests bufferingSize: 100 # Add buffering for better performance fields: defaultMode: drop # Start with dropping all fields names: ClientAddr: keep # Keep client address for IP tracking ClientHost: keep # Keep client host for IP tracking RequestMethod: keep # Keep request method for tracking RequestPath: keep # Keep request path for tracking RequestProtocol: keep # Keep request protocol for tracking DownstreamStatus: keep # Keep downstream status for tracking DownstreamContentSize: keep # Keep downstream content size for tracking Duration: keep # Keep request duration for tracking ServiceName: keep # Keep service name for tracking StartUTC: keep # Keep start time for tracking TLSVersion: keep # Keep TLS version for tracking TLSCipher: keep # Keep TLS cipher for tracking RetryAttempts: keep # Keep retry attempts for tracking headers: defaultMode: drop # Start with dropping all headers names: User-Agent: keep # Keep user agent for tracking X-Real-Ip: keep # Keep real IP for tracking X-Forwarded-For: keep # Keep forwarded IP for tracking X-Forwarded-Proto: keep # Keep forwarded protocol for tracking Content-Type: keep # Keep content type for tracking Authorization: redact # Redact sensitive information Cookie: redact # Redact sensitive information certificatesResolvers: letsencrypt: acme: httpChallenge: entryPoint: web email: "{{.LetsEncryptEmail}}" storage: "/letsencrypt/acme.json" caServer: "https://acme-v02.api.letsencrypt.org/directory" entryPoints: web: address: ":80" websecure: address: ":443" transport: respondingTimeouts: readTimeout: "30m" http: tls: certResolver: "letsencrypt" middlewares: - crowdsec@file serversTransport: insecureSkipVerify: true ================================================ FILE: install/config/docker-compose.yml ================================================ name: pangolin services: pangolin: image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}} container_name: pangolin restart: unless-stopped deploy: resources: limits: memory: 1g reservations: memory: 256m volumes: - ./config:/app/config healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"] interval: "10s" timeout: "10s" retries: 15 {{if .InstallGerbil}} gerbil: image: docker.io/fosrl/gerbil:{{.GerbilVersion}} container_name: gerbil restart: unless-stopped depends_on: pangolin: condition: service_healthy command: - --reachableAt=http://gerbil:3004 - --generateAndSaveKeyTo=/var/config/key - --remoteConfig=http://pangolin:3001/api/v1/ volumes: - ./config/:/var/config cap_add: - NET_ADMIN - SYS_MODULE ports: - 51820:51820/udp - 21820:21820/udp - 443:443 - 80:80 {{end}} traefik: image: docker.io/traefik:v3.6 container_name: traefik restart: unless-stopped {{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} ports: - 443:443 - 80:80 {{end}} depends_on: pangolin: condition: service_healthy command: - --configFile=/etc/traefik/traefik_config.yml volumes: - ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration - ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates - ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs networks: default: driver: bridge name: pangolin {{if .EnableIPv6}} enable_ipv6: true{{end}} ================================================ FILE: install/config/traefik/dynamic_config.yml ================================================ http: middlewares: badger: plugin: badger: disableForwardAuth: true redirect-to-https: redirectScheme: scheme: https routers: # HTTP to HTTPS redirect router main-app-router-redirect: rule: "Host(`{{.DashboardDomain}}`)" service: next-service entryPoints: - web middlewares: - redirect-to-https - badger # Next.js router (handles everything except API and WebSocket paths) next-router: rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" service: next-service entryPoints: - websecure middlewares: - badger tls: certResolver: letsencrypt # API router (handles /api/v1 paths) api-router: rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" service: api-service entryPoints: - websecure middlewares: - badger tls: certResolver: letsencrypt # WebSocket router ws-router: rule: "Host(`{{.DashboardDomain}}`)" service: api-service entryPoints: - websecure middlewares: - badger tls: certResolver: letsencrypt services: next-service: loadBalancer: servers: - url: "http://pangolin:3002" # Next.js server api-service: loadBalancer: servers: - url: "http://pangolin:3000" # API/WebSocket server tcp: serversTransports: pp-transport-v1: proxyProtocol: version: 1 pp-transport-v2: proxyProtocol: version: 2 ================================================ FILE: install/config/traefik/traefik_config.yml ================================================ api: insecure: true dashboard: true providers: http: endpoint: "http://pangolin:3001/api/v1/traefik-config" pollInterval: "5s" file: filename: "/etc/traefik/dynamic_config.yml" experimental: plugins: badger: moduleName: "github.com/fosrl/badger" version: "{{.BadgerVersion}}" log: level: "INFO" format: "common" maxSize: 100 maxBackups: 3 maxAge: 3 compress: true certificatesResolvers: letsencrypt: acme: httpChallenge: entryPoint: web email: "{{.LetsEncryptEmail}}" storage: "/letsencrypt/acme.json" caServer: "https://acme-v02.api.letsencrypt.org/directory" entryPoints: web: address: ":80" websecure: address: ":443" transport: respondingTimeouts: readTimeout: "30m" http: tls: certResolver: "letsencrypt" encodedCharacters: allowEncodedSlash: true allowEncodedQuestionMark: true serversTransport: insecureSkipVerify: true ping: entryPoint: "web" ================================================ FILE: install/config.go ================================================ package main import ( "bytes" "fmt" "os" "os/exec" "strings" "gopkg.in/yaml.v3" ) // TraefikConfig represents the structure of the main Traefik configuration type TraefikConfig struct { Experimental struct { Plugins struct { Badger struct { Version string `yaml:"version"` } `yaml:"badger"` } `yaml:"plugins"` } `yaml:"experimental"` CertificatesResolvers struct { LetsEncrypt struct { Acme struct { Email string `yaml:"email"` } `yaml:"acme"` } `yaml:"letsencrypt"` } `yaml:"certificatesResolvers"` } // DynamicConfig represents the structure of the dynamic configuration type DynamicConfig struct { HTTP struct { Routers map[string]struct { Rule string `yaml:"rule"` } `yaml:"routers"` } `yaml:"http"` } // TraefikConfigValues holds the extracted configuration values type TraefikConfigValues struct { DashboardDomain string LetsEncryptEmail string BadgerVersion string } // AppConfig represents the app section of the config.yml type AppConfig struct { App struct { DashboardURL string `yaml:"dashboard_url"` LogLevel string `yaml:"log_level"` } `yaml:"app"` } type AppConfigValues struct { DashboardURL string LogLevel string } // ReadTraefikConfig reads and extracts values from Traefik configuration files func ReadTraefikConfig(mainConfigPath string) (*TraefikConfigValues, error) { // Read main config file mainConfigData, err := os.ReadFile(mainConfigPath) if err != nil { return nil, fmt.Errorf("error reading main config file: %w", err) } var mainConfig TraefikConfig if err := yaml.Unmarshal(mainConfigData, &mainConfig); err != nil { return nil, fmt.Errorf("error parsing main config file: %w", err) } // Extract values values := &TraefikConfigValues{ BadgerVersion: mainConfig.Experimental.Plugins.Badger.Version, LetsEncryptEmail: mainConfig.CertificatesResolvers.LetsEncrypt.Acme.Email, } return values, nil } func ReadAppConfig(configPath string) (*AppConfigValues, error) { // Read config file configData, err := os.ReadFile(configPath) if err != nil { return nil, fmt.Errorf("error reading config file: %w", err) } var appConfig AppConfig if err := yaml.Unmarshal(configData, &appConfig); err != nil { return nil, fmt.Errorf("error parsing config file: %w", err) } values := &AppConfigValues{ DashboardURL: appConfig.App.DashboardURL, LogLevel: appConfig.App.LogLevel, } return values, nil } // findPattern finds the start of a pattern in a string func findPattern(s, pattern string) int { return bytes.Index([]byte(s), []byte(pattern)) } func copyDockerService(sourceFile, destFile, serviceName string) error { // Read source file sourceData, err := os.ReadFile(sourceFile) if err != nil { return fmt.Errorf("error reading source file: %w", err) } // Read destination file destData, err := os.ReadFile(destFile) if err != nil { return fmt.Errorf("error reading destination file: %w", err) } // Parse source Docker Compose YAML var sourceCompose map[string]any if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil { return fmt.Errorf("error parsing source Docker Compose file: %w", err) } // Parse destination Docker Compose YAML var destCompose map[string]any if err := yaml.Unmarshal(destData, &destCompose); err != nil { return fmt.Errorf("error parsing destination Docker Compose file: %w", err) } // Get services section from source sourceServices, ok := sourceCompose["services"].(map[string]any) if !ok { return fmt.Errorf("services section not found in source file or has invalid format") } // Get the specific service configuration serviceConfig, ok := sourceServices[serviceName] if !ok { return fmt.Errorf("service '%s' not found in source file", serviceName) } // Get or create services section in destination destServices, ok := destCompose["services"].(map[string]any) if !ok { // If services section doesn't exist, create it destServices = make(map[string]any) destCompose["services"] = destServices } // Update service in destination destServices[serviceName] = serviceConfig // Marshal updated destination YAML // Use yaml.v3 encoder to preserve formatting and comments // updatedData, err := yaml.Marshal(destCompose) updatedData, err := MarshalYAMLWithIndent(destCompose, 2) if err != nil { return fmt.Errorf("error marshaling updated Docker Compose file: %w", err) } // Write updated YAML back to destination file if err := os.WriteFile(destFile, updatedData, 0644); err != nil { return fmt.Errorf("error writing to destination file: %w", err) } return nil } func backupConfig() error { // Backup docker-compose.yml if _, err := os.Stat("docker-compose.yml"); err == nil { if err := copyFile("docker-compose.yml", "docker-compose.yml.backup"); err != nil { return fmt.Errorf("failed to backup docker-compose.yml: %v", err) } } // Backup config directory if _, err := os.Stat("config"); err == nil { cmd := exec.Command("tar", "-czvf", "config.tar.gz", "config") if err := cmd.Run(); err != nil { return fmt.Errorf("failed to backup config directory: %v", err) } } return nil } func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) { buffer := new(bytes.Buffer) encoder := yaml.NewEncoder(buffer) encoder.SetIndent(indent) if err := encoder.Encode(data); err != nil { return nil, err } defer encoder.Close() return buffer.Bytes(), nil } func replaceInFile(filepath, oldStr, newStr string) error { // Read the file content content, err := os.ReadFile(filepath) if err != nil { return fmt.Errorf("error reading file: %v", err) } // Replace the string newContent := strings.ReplaceAll(string(content), oldStr, newStr) // Write the modified content back to the file err = os.WriteFile(filepath, []byte(newContent), 0644) if err != nil { return fmt.Errorf("error writing file: %v", err) } return nil } func CheckAndAddTraefikLogVolume(composePath string) error { // Read the docker-compose.yml file data, err := os.ReadFile(composePath) if err != nil { return fmt.Errorf("error reading compose file: %w", err) } // Parse YAML into a generic map var compose map[string]any if err := yaml.Unmarshal(data, &compose); err != nil { return fmt.Errorf("error parsing compose file: %w", err) } // Get services section services, ok := compose["services"].(map[string]any) if !ok { return fmt.Errorf("services section not found or invalid") } // Get traefik service traefik, ok := services["traefik"].(map[string]any) if !ok { return fmt.Errorf("traefik service not found or invalid") } // Check volumes logVolume := "./config/traefik/logs:/var/log/traefik" var volumes []any if existingVolumes, ok := traefik["volumes"].([]any); ok { // Check if volume already exists for _, v := range existingVolumes { if v.(string) == logVolume { fmt.Println("Traefik log volume is already configured") return nil } } volumes = existingVolumes } // Add new volume volumes = append(volumes, logVolume) traefik["volumes"] = volumes // Write updated config back to file newData, err := MarshalYAMLWithIndent(compose, 2) if err != nil { return fmt.Errorf("error marshaling updated compose file: %w", err) } if err := os.WriteFile(composePath, newData, 0644); err != nil { return fmt.Errorf("error writing updated compose file: %w", err) } fmt.Println("Added traefik log volume and created logs directory") return nil } // MergeYAML merges two YAML files, where the contents of the second file // are merged into the first file. In case of conflicts, values from the // second file take precedence. func MergeYAML(baseFile, overlayFile string) error { // Read the base YAML file baseContent, err := os.ReadFile(baseFile) if err != nil { return fmt.Errorf("error reading base file: %v", err) } // Read the overlay YAML file overlayContent, err := os.ReadFile(overlayFile) if err != nil { return fmt.Errorf("error reading overlay file: %v", err) } // Parse base YAML into a map var baseMap map[string]any if err := yaml.Unmarshal(baseContent, &baseMap); err != nil { return fmt.Errorf("error parsing base YAML: %v", err) } // Parse overlay YAML into a map var overlayMap map[string]any if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil { return fmt.Errorf("error parsing overlay YAML: %v", err) } // Merge the overlay into the base merged := mergeMap(baseMap, overlayMap) // Marshal the merged result back to YAML mergedContent, err := MarshalYAMLWithIndent(merged, 2) if err != nil { return fmt.Errorf("error marshaling merged YAML: %v", err) } // Write the merged content back to the base file if err := os.WriteFile(baseFile, mergedContent, 0644); err != nil { return fmt.Errorf("error writing merged YAML: %v", err) } return nil } // mergeMap recursively merges two maps func mergeMap(base, overlay map[string]any) map[string]any { result := make(map[string]any) // Copy all key-values from base map for k, v := range base { result[k] = v } // Merge overlay values for k, v := range overlay { // If both maps have the same key and both values are maps, merge recursively if baseVal, ok := base[k]; ok { if baseMap, isBaseMap := baseVal.(map[string]any); isBaseMap { if overlayMap, isOverlayMap := v.(map[string]any); isOverlayMap { result[k] = mergeMap(baseMap, overlayMap) continue } } } // Otherwise, overlay value takes precedence result[k] = v } return result } ================================================ FILE: install/containers.go ================================================ package main import ( "bytes" "fmt" "os" "os/exec" "os/user" "runtime" "strconv" "strings" "time" ) func waitForContainer(containerName string, containerType SupportedContainer) error { maxAttempts := 30 retryInterval := time.Second * 2 for attempt := 0; attempt < maxAttempts; attempt++ { // Check if container is running cmd := exec.Command(string(containerType), "container", "inspect", "-f", "{{.State.Running}}", containerName) var out bytes.Buffer cmd.Stdout = &out if err := cmd.Run(); err != nil { // If the container doesn't exist or there's another error, wait and retry time.Sleep(retryInterval) continue } isRunning := strings.TrimSpace(out.String()) == "true" if isRunning { return nil } // Container exists but isn't running yet, wait and retry time.Sleep(retryInterval) } return fmt.Errorf("container %s did not start within %v seconds", containerName, maxAttempts*int(retryInterval.Seconds())) } func installDocker() error { // Detect Linux distribution cmd := exec.Command("cat", "/etc/os-release") output, err := cmd.Output() if err != nil { return fmt.Errorf("failed to detect Linux distribution: %v", err) } osRelease := string(output) // Detect system architecture archCmd := exec.Command("uname", "-m") archOutput, err := archCmd.Output() if err != nil { return fmt.Errorf("failed to detect system architecture: %v", err) } arch := strings.TrimSpace(string(archOutput)) // Map architecture to Docker's architecture naming var dockerArch string switch arch { case "x86_64": dockerArch = "amd64" case "aarch64": dockerArch = "arm64" default: return fmt.Errorf("unsupported architecture: %s", arch) } var installCmd *exec.Cmd switch { case strings.Contains(osRelease, "ID=ubuntu"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` apt-get update && apt-get install -y apt-transport-https ca-certificates curl gpg && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin `, dockerArch)) case strings.Contains(osRelease, "ID=debian"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` apt-get update && apt-get install -y apt-transport-https ca-certificates curl gpg && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && apt-get update && apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin `, dockerArch)) case strings.Contains(osRelease, "ID=fedora"): // Detect Fedora version to handle DNF 5 changes versionCmd := exec.Command("bash", "-c", "grep VERSION_ID /etc/os-release | cut -d'=' -f2 | tr -d '\"'") versionOutput, err := versionCmd.Output() var fedoraVersion int if err == nil { if v, parseErr := strconv.Atoi(strings.TrimSpace(string(versionOutput))); parseErr == nil { fedoraVersion = v } } // Use appropriate DNF syntax based on version var repoCmd string if fedoraVersion >= 41 { // DNF 5 syntax for Fedora 41+ repoCmd = "dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo" } else { // DNF 4 syntax for Fedora < 41 repoCmd = "dnf config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo" } installCmd = exec.Command("bash", "-c", fmt.Sprintf(` dnf -y install dnf-plugins-core && %s && dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin `, repoCmd)) case strings.Contains(osRelease, "ID=opensuse") || strings.Contains(osRelease, "ID=\"opensuse-"): installCmd = exec.Command("bash", "-c", ` zypper install -y docker docker-compose && systemctl enable docker `) case strings.Contains(osRelease, "ID=rhel") || strings.Contains(osRelease, "ID=\"rhel"): installCmd = exec.Command("bash", "-c", ` dnf remove -y runc && dnf -y install yum-utils && dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo && dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin && systemctl enable docker `) case strings.Contains(osRelease, "ID=amzn"): installCmd = exec.Command("bash", "-c", ` yum update -y && yum install -y docker && systemctl enable docker && usermod -a -G docker ec2-user `) default: return fmt.Errorf("unsupported Linux distribution") } installCmd.Stdout = os.Stdout installCmd.Stderr = os.Stderr return installCmd.Run() } func startDockerService() error { switch runtime.GOOS { case "linux": cmd := exec.Command("systemctl", "enable", "--now", "docker") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() case "darwin": // On macOS, Docker is usually started via the Docker Desktop application fmt.Println("Please start Docker Desktop manually on macOS.") return nil } return fmt.Errorf("unsupported operating system for starting Docker service") } func isDockerInstalled() bool { return isContainerInstalled("docker") } func isPodmanInstalled() bool { return isContainerInstalled("podman") && isContainerInstalled("podman-compose") } func isContainerInstalled(container string) bool { cmd := exec.Command(container, "--version") if err := cmd.Run(); err != nil { return false } return true } func isUserInDockerGroup() bool { if runtime.GOOS == "darwin" { // Docker group is not applicable on macOS // So we assume that the user can run Docker commands return true } if os.Geteuid() == 0 { return true // Root user can run Docker commands anyway } // Check if the current user is in the docker group if dockerGroup, err := user.LookupGroup("docker"); err == nil { if currentUser, err := user.Current(); err == nil { if currentUserGroupIds, err := currentUser.GroupIds(); err == nil { for _, groupId := range currentUserGroupIds { if groupId == dockerGroup.Gid { return true } } } } } // Eventually, if any of the checks fail, we assume the user cannot run Docker commands return false } // isDockerRunning checks if the Docker daemon is running by using the `docker info` command. func isDockerRunning() bool { cmd := exec.Command("docker", "info") if err := cmd.Run(); err != nil { return false } return true } func isPodmanRunning() bool { cmd := exec.Command("podman", "info") if err := cmd.Run(); err != nil { return false } return true } // detectContainerType detects whether the system is currently using Docker or Podman // by checking which container runtime is running and has containers func detectContainerType() SupportedContainer { // Check if we have running containers with podman if isPodmanRunning() { cmd := exec.Command("podman", "ps", "-q") output, err := cmd.Output() if err == nil && len(strings.TrimSpace(string(output))) > 0 { return Podman } } // Check if we have running containers with docker if isDockerRunning() { cmd := exec.Command("docker", "ps", "-q") output, err := cmd.Output() if err == nil && len(strings.TrimSpace(string(output))) > 0 { return Docker } } // If no containers are running, check which one is installed and running if isPodmanRunning() && isPodmanInstalled() { return Podman } if isDockerRunning() && isDockerInstalled() { return Docker } return Undefined } // executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied func executeDockerComposeCommandWithArgs(args ...string) error { var cmd *exec.Cmd var useNewStyle bool if !isDockerInstalled() { return fmt.Errorf("docker is not installed") } checkCmd := exec.Command("docker", "compose", "version") if err := checkCmd.Run(); err == nil { useNewStyle = true } else { checkCmd = exec.Command("docker-compose", "version") if err := checkCmd.Run(); err == nil { useNewStyle = false } else { return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available") } } if useNewStyle { cmd = exec.Command("docker", append([]string{"compose"}, args...)...) } else { cmd = exec.Command("docker-compose", args...) } cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } // pullContainers pulls the containers using the appropriate command. func pullContainers(containerType SupportedContainer) error { fmt.Println("Pulling the container images...") if containerType == Podman { if err := run("podman-compose", "-f", "docker-compose.yml", "pull"); err != nil { return fmt.Errorf("failed to pull the containers: %v", err) } return nil } if containerType == Docker { if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "pull", "--policy", "always"); err != nil { return fmt.Errorf("failed to pull the containers: %v", err) } return nil } return fmt.Errorf("unsupported container type: %s", containerType) } // startContainers starts the containers using the appropriate command. func startContainers(containerType SupportedContainer) error { fmt.Println("Starting containers...") if containerType == Podman { if err := run("podman-compose", "-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil { return fmt.Errorf("failed start containers: %v", err) } return nil } if containerType == Docker { if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil { return fmt.Errorf("failed to start containers: %v", err) } return nil } return fmt.Errorf("unsupported container type: %s", containerType) } // stopContainers stops the containers using the appropriate command. func stopContainers(containerType SupportedContainer) error { fmt.Println("Stopping containers...") if containerType == Podman { if err := run("podman-compose", "-f", "docker-compose.yml", "down"); err != nil { return fmt.Errorf("failed to stop containers: %v", err) } return nil } if containerType == Docker { if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "down"); err != nil { return fmt.Errorf("failed to stop containers: %v", err) } return nil } return fmt.Errorf("unsupported container type: %s", containerType) } // restartContainer restarts a specific container using the appropriate command. func restartContainer(container string, containerType SupportedContainer) error { fmt.Println("Restarting containers...") if containerType == Podman { if err := run("podman-compose", "-f", "docker-compose.yml", "restart"); err != nil { return fmt.Errorf("failed to stop the container \"%s\": %v", container, err) } return nil } if containerType == Docker { if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "restart", container); err != nil { return fmt.Errorf("failed to stop the container \"%s\": %v", container, err) } return nil } return fmt.Errorf("unsupported container type: %s", containerType) } ================================================ FILE: install/crowdsec.go ================================================ package main import ( "bytes" "fmt" "log" "os" "os/exec" "strings" "gopkg.in/yaml.v3" ) func installCrowdsec(config Config) error { if err := stopContainers(config.InstallationContainerType); err != nil { return fmt.Errorf("failed to stop containers: %v", err) } // Run installation steps if err := backupConfig(); err != nil { return fmt.Errorf("backup failed: %v", err) } if err := createConfigFiles(config); err != nil { fmt.Printf("Error creating config files: %v\n", err) os.Exit(1) } if err := os.MkdirAll("config/crowdsec/db", 0755); err != nil { fmt.Printf("Error creating config files: %v\n", err) os.Exit(1) } if err := os.MkdirAll("config/crowdsec/acquis.d", 0755); err != nil { fmt.Printf("Error creating config files: %v\n", err) os.Exit(1) } if err := os.MkdirAll("config/traefik/logs", 0755); err != nil { fmt.Printf("Error creating config files: %v\n", err) os.Exit(1) } if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { fmt.Printf("Error copying docker service: %v\n", err) os.Exit(1) } if err := MergeYAML("config/traefik/traefik_config.yml", "config/crowdsec/traefik_config.yml"); err != nil { fmt.Printf("Error copying entry points: %v\n", err) os.Exit(1) } // delete the 2nd file if err := os.Remove("config/crowdsec/traefik_config.yml"); err != nil { fmt.Printf("Error removing file: %v\n", err) os.Exit(1) } if err := MergeYAML("config/traefik/dynamic_config.yml", "config/crowdsec/dynamic_config.yml"); err != nil { fmt.Printf("Error copying entry points: %v\n", err) os.Exit(1) } // delete the 2nd file if err := os.Remove("config/crowdsec/dynamic_config.yml"); err != nil { fmt.Printf("Error removing file: %v\n", err) os.Exit(1) } if err := os.Remove("config/crowdsec/docker-compose.yml"); err != nil { fmt.Printf("Error removing file: %v\n", err) os.Exit(1) } if err := CheckAndAddTraefikLogVolume("docker-compose.yml"); err != nil { fmt.Printf("Error checking and adding Traefik log volume: %v\n", err) os.Exit(1) } // check and add the service dependency of crowdsec to traefik if err := CheckAndAddCrowdsecDependency("docker-compose.yml"); err != nil { fmt.Printf("Error adding crowdsec dependency to traefik: %v\n", err) os.Exit(1) } if err := startContainers(config.InstallationContainerType); err != nil { return fmt.Errorf("failed to start containers: %v", err) } // get API key apiKey, err := GetCrowdSecAPIKey(config.InstallationContainerType) if err != nil { return fmt.Errorf("failed to get API key: %v", err) } config.TraefikBouncerKey = apiKey if err := replaceInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK", config.TraefikBouncerKey); err != nil { return fmt.Errorf("failed to replace bouncer key: %v", err) } if err := restartContainer("traefik", config.InstallationContainerType); err != nil { return fmt.Errorf("failed to restart containers: %v", err) } if checkIfTextInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK") { fmt.Println("Failed to replace bouncer key! Please retrieve the key and replace it in the config/traefik/dynamic_config.yml file using the following command:") fmt.Printf(" %s exec crowdsec cscli bouncers add traefik-bouncer\n", config.InstallationContainerType) } return nil } func checkIsCrowdsecInstalledInCompose() bool { // Read docker-compose.yml content, err := os.ReadFile("docker-compose.yml") if err != nil { return false } // Check for crowdsec service return bytes.Contains(content, []byte("crowdsec:")) } func GetCrowdSecAPIKey(containerType SupportedContainer) (string, error) { // First, ensure the container is running if err := waitForContainer("crowdsec", containerType); err != nil { return "", fmt.Errorf("waiting for container: %w", err) } // Execute the command to get the API key cmd := exec.Command(string(containerType), "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw") var out bytes.Buffer cmd.Stdout = &out if err := cmd.Run(); err != nil { return "", fmt.Errorf("executing command: %w", err) } // Trim any whitespace from the output apiKey := strings.TrimSpace(out.String()) if apiKey == "" { return "", fmt.Errorf("empty API key returned") } return apiKey, nil } func checkIfTextInFile(file, text string) bool { // Read file content, err := os.ReadFile(file) if err != nil { return false } // Check for text return bytes.Contains(content, []byte(text)) } func CheckAndAddCrowdsecDependency(composePath string) error { // Read the docker-compose.yml file data, err := os.ReadFile(composePath) if err != nil { return fmt.Errorf("error reading compose file: %w", err) } // Parse YAML into a generic map var compose map[string]any if err := yaml.Unmarshal(data, &compose); err != nil { return fmt.Errorf("error parsing compose file: %w", err) } // Get services section services, ok := compose["services"].(map[string]any) if !ok { return fmt.Errorf("services section not found or invalid") } // Get traefik service traefik, ok := services["traefik"].(map[string]any) if !ok { return fmt.Errorf("traefik service not found or invalid") } // Get dependencies dependsOn, ok := traefik["depends_on"].(map[string]any) if ok { // Append the new block for crowdsec dependsOn["crowdsec"] = map[string]any{ "condition": "service_healthy", } } else { // No dependencies exist, create it traefik["depends_on"] = map[string]any{ "crowdsec": map[string]any{ "condition": "service_healthy", }, } } // Marshal the modified data back to YAML with indentation modifiedData, err := MarshalYAMLWithIndent(compose, 2) // Set indentation to 2 spaces if err != nil { log.Fatalf("error marshaling YAML: %v", err) } if err := os.WriteFile(composePath, modifiedData, 0644); err != nil { return fmt.Errorf("error writing updated compose file: %w", err) } fmt.Println("Added dependency of crowdsec to traefik") return nil } ================================================ FILE: install/get-installer.sh ================================================ #!/bin/bash # Get installer - Cross-platform installation script # Usage: curl -fsSL https://raw.githubusercontent.com/fosrl/installer/refs/heads/main/get-installer.sh | bash set -e # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' # No Color # GitHub repository info REPO="fosrl/pangolin" GITHUB_API_URL="https://api.github.com/repos/${REPO}/releases/latest" # Function to print colored output print_status() { echo -e "${GREEN}[INFO]${NC} $1" } print_warning() { echo -e "${YELLOW}[WARN]${NC} $1" } print_error() { echo -e "${RED}[ERROR]${NC} $1" } # Function to get latest version from GitHub API get_latest_version() { local latest_info if command -v curl >/dev/null 2>&1; then latest_info=$(curl -fsSL "$GITHUB_API_URL" 2>/dev/null) elif command -v wget >/dev/null 2>&1; then latest_info=$(wget -qO- "$GITHUB_API_URL" 2>/dev/null) else print_error "Neither curl nor wget is available. Please install one of them." >&2 exit 1 fi if [ -z "$latest_info" ]; then print_error "Failed to fetch latest version information" >&2 exit 1 fi # Extract version from JSON response (works without jq) local version=$(echo "$latest_info" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') if [ -z "$version" ]; then print_error "Could not parse version from GitHub API response" >&2 exit 1 fi # Remove 'v' prefix if present version=$(echo "$version" | sed 's/^v//') echo "$version" } # Detect OS and architecture detect_platform() { local os arch # Detect OS - only support Linux case "$(uname -s)" in Linux*) os="linux" ;; *) print_error "Unsupported operating system: $(uname -s). Only Linux is supported." exit 1 ;; esac # Detect architecture - only support amd64 and arm64 case "$(uname -m)" in x86_64|amd64) arch="amd64" ;; arm64|aarch64) arch="arm64" ;; *) print_error "Unsupported architecture: $(uname -m). Only amd64 and arm64 are supported on Linux." exit 1 ;; esac echo "${os}_${arch}" } # Get installation directory get_install_dir() { # Install to the current directory local install_dir="$(pwd)" if [ ! -d "$install_dir" ]; then print_error "Installation directory does not exist: $install_dir" exit 1 fi echo "$install_dir" } # Download and install installer install_installer() { local platform="$1" local install_dir="$2" local binary_name="installer_${platform}" local download_url="${BASE_URL}/${binary_name}" local temp_file="/tmp/installer" local final_path="${install_dir}/installer" print_status "Downloading installer from ${download_url}" # Download the binary if command -v curl >/dev/null 2>&1; then curl -fsSL "$download_url" -o "$temp_file" elif command -v wget >/dev/null 2>&1; then wget -q "$download_url" -O "$temp_file" else print_error "Neither curl nor wget is available. Please install one of them." exit 1 fi # Create install directory if it doesn't exist mkdir -p "$install_dir" # Move binary to install directory mv "$temp_file" "$final_path" # Make executable chmod +x "$final_path" print_status "Installer downloaded to ${final_path}" } # Verify installation verify_installation() { local install_dir="$1" local installer_path="${install_dir}/installer" if [ -f "$installer_path" ] && [ -x "$installer_path" ]; then print_status "Installation successful!" return 0 else print_error "Installation failed. Binary not found or not executable." return 1 fi } # Main installation process main() { print_status "Installing latest version of installer..." # Get latest version print_status "Fetching latest version from GitHub..." VERSION=$(get_latest_version) print_status "Latest version: v${VERSION}" # Set base URL with the fetched version BASE_URL="https://github.com/${REPO}/releases/download/${VERSION}" # Detect platform PLATFORM=$(detect_platform) print_status "Detected platform: ${PLATFORM}" # Get install directory INSTALL_DIR=$(get_install_dir) print_status "Install directory: ${INSTALL_DIR}" # Install installer install_installer "$PLATFORM" "$INSTALL_DIR" # Verify installation if verify_installation "$INSTALL_DIR"; then print_status "Installer is ready to use!" else exit 1 fi } # Run main function main "$@" ================================================ FILE: install/go.mod ================================================ module installer go 1.25.0 require ( github.com/charmbracelet/huh v0.8.0 github.com/charmbracelet/lipgloss v1.1.0 golang.org/x/term v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sync v0.15.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.23.0 // indirect ) ================================================ FILE: install/go.sum ================================================ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: install/input.go ================================================ package main import ( "errors" "fmt" "os" "strconv" "github.com/charmbracelet/huh" "golang.org/x/term" ) // pangolinTheme is the custom theme using brand colors var pangolinTheme = ThemePangolin() // isAccessibleMode checks if we should use accessible mode (simple prompts) // This is true for: non-TTY, TERM=dumb, or ACCESSIBLE env var set func isAccessibleMode() bool { // Check if stdin is not a terminal (piped input, CI, etc.) if !term.IsTerminal(int(os.Stdin.Fd())) { return true } // Check for dumb terminal if os.Getenv("TERM") == "dumb" { return true } // Check for explicit accessible mode request if os.Getenv("ACCESSIBLE") != "" { return true } return false } // handleAbort checks if the error is a user abort (Ctrl+C) and exits if so func handleAbort(err error) { if err != nil && errors.Is(err, huh.ErrUserAborted) { fmt.Println("\nInstallation cancelled.") os.Exit(0) } } // runField runs a single field with the Pangolin theme, handling accessible mode func runField(field huh.Field) error { if isAccessibleMode() { return field.RunAccessible(os.Stdout, os.Stdin) } form := huh.NewForm(huh.NewGroup(field)).WithTheme(pangolinTheme) return form.Run() } func readString(prompt string, defaultValue string) string { var value string title := prompt if defaultValue != "" { title = fmt.Sprintf("%s (default: %s)", prompt, defaultValue) } input := huh.NewInput(). Title(title). Value(&value) // If no default value, this field is required if defaultValue == "" { input = input.Validate(func(s string) error { if s == "" { return fmt.Errorf("this field is required") } return nil }) } err := runField(input) handleAbort(err) if value == "" { value = defaultValue } // Print the answer so it remains visible in terminal history (skip in accessible mode as it already shows) if !isAccessibleMode() { fmt.Printf("%s: %s\n", prompt, value) } return value } func readStringNoDefault(prompt string) string { var value string for { input := huh.NewInput(). Title(prompt). Value(&value). Validate(func(s string) error { if s == "" { return fmt.Errorf("this field is required") } return nil }) err := runField(input) handleAbort(err) if value != "" { // Print the answer so it remains visible in terminal history if !isAccessibleMode() { fmt.Printf("%s: %s\n", prompt, value) } return value } } } func readPassword(prompt string) string { var value string for { input := huh.NewInput(). Title(prompt). Value(&value). EchoMode(huh.EchoModePassword). Validate(func(s string) error { if s == "" { return fmt.Errorf("password is required") } return nil }) err := runField(input) handleAbort(err) if value != "" { // Print confirmation without revealing the password if !isAccessibleMode() { fmt.Printf("%s: %s\n", prompt, "********") } return value } } } func readBool(prompt string, defaultValue bool) bool { var value = defaultValue confirm := huh.NewConfirm(). Title(prompt). Value(&value). Affirmative("Yes"). Negative("No") err := runField(confirm) handleAbort(err) // Print the answer so it remains visible in terminal history if !isAccessibleMode() { answer := "No" if value { answer = "Yes" } fmt.Printf("%s: %s\n", prompt, answer) } return value } func readBoolNoDefault(prompt string) bool { var value bool confirm := huh.NewConfirm(). Title(prompt). Value(&value). Affirmative("Yes"). Negative("No") err := runField(confirm) handleAbort(err) // Print the answer so it remains visible in terminal history if !isAccessibleMode() { answer := "No" if value { answer = "Yes" } fmt.Printf("%s: %s\n", prompt, answer) } return value } func readInt(prompt string, defaultValue int) int { var value string title := fmt.Sprintf("%s (default: %d)", prompt, defaultValue) input := huh.NewInput(). Title(title). Value(&value). Validate(func(s string) error { if s == "" { return nil } _, err := strconv.Atoi(s) if err != nil { return fmt.Errorf("please enter a valid number") } return nil }) err := runField(input) handleAbort(err) if value == "" { // Print the answer so it remains visible in terminal history if !isAccessibleMode() { fmt.Printf("%s: %d\n", prompt, defaultValue) } return defaultValue } result, err := strconv.Atoi(value) if err != nil { if !isAccessibleMode() { fmt.Printf("%s: %d\n", prompt, defaultValue) } return defaultValue } // Print the answer so it remains visible in terminal history if !isAccessibleMode() { fmt.Printf("%s: %d\n", prompt, result) } return result } ================================================ FILE: install/input.txt ================================================ docker example.com pangolin.example.com yes admin@example.com yes admin@example.com Password123! Password123! yes no no no yes ================================================ FILE: install/main.go ================================================ package main import ( "crypto/rand" "embed" "encoding/base64" "fmt" "io" "io/fs" "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" "text/template" "time" ) // Version variables injected at build time via -ldflags var ( pangolinVersion string gerbilVersion string badgerVersion string ) func loadVersions(config *Config) { config.PangolinVersion = pangolinVersion config.GerbilVersion = gerbilVersion config.BadgerVersion = badgerVersion } //go:embed config/* var configFiles embed.FS type Config struct { InstallationContainerType SupportedContainer PangolinVersion string GerbilVersion string BadgerVersion string BaseDomain string DashboardDomain string EnableIPv6 bool LetsEncryptEmail string EnableEmail bool EmailSMTPHost string EmailSMTPPort int EmailSMTPUser string EmailSMTPPass string EmailNoReply string InstallGerbil bool TraefikBouncerKey string DoCrowdsecInstall bool EnableGeoblocking bool Secret string IsEnterprise bool } type SupportedContainer string const ( Docker SupportedContainer = "docker" Podman SupportedContainer = "podman" Undefined SupportedContainer = "undefined" ) func main() { // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking fmt.Println("Welcome to the Pangolin installer!") fmt.Println("This installer will help you set up Pangolin on your server.") fmt.Println("\nPlease make sure you have the following prerequisites:") fmt.Println("- Open TCP ports 80 and 443 and UDP ports 51820 and 21820 on your VPS and firewall.") fmt.Println("\nLets get started!") if os.Geteuid() == 0 { // WE NEED TO BE SUDO TO CHECK THIS for _, p := range []int{80, 443} { if err := checkPortsAvailable(p); err != nil { fmt.Fprintln(os.Stderr, err) fmt.Printf("Please close any services on ports 80/443 in order to run the installation smoothly. If you already have the Pangolin stack running, shut them down before proceeding.\n") os.Exit(1) } } } var config Config var alreadyInstalled = false // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { config = collectUserInput() loadVersions(&config) config.DoCrowdsecInstall = false config.Secret = generateRandomSecretKey() fmt.Println("\n=== Generating Configuration Files ===") if err := createConfigFiles(config); err != nil { fmt.Printf("Error creating config files: %v\n", err) os.Exit(1) } if err := moveFile("config/docker-compose.yml", "docker-compose.yml"); err != nil { fmt.Printf("Error moving docker-compose.yml: %v\n", err) os.Exit(1) } fmt.Println("\nConfiguration files created successfully!") // Download MaxMind database if requested if config.EnableGeoblocking { fmt.Println("\n=== Downloading MaxMind Database ===") if err := downloadMaxMindDatabase(); err != nil { fmt.Printf("Error downloading MaxMind database: %v\n", err) fmt.Println("You can download it manually later if needed.") } } fmt.Println("\n=== Starting installation ===") if readBool("Would you like to install and start the containers?", true) { config.InstallationContainerType = podmanOrDocker() if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker { if readBool("Docker is not installed. Would you like to install it?", true) { if err := installDocker(); err != nil { fmt.Printf("Error installing Docker: %v\n", err) return } // try to start docker service but ignore errors if err := startDockerService(); err != nil { fmt.Println("Error starting Docker service:", err) } else { fmt.Println("Docker service started successfully!") } // wait 10 seconds for docker to start checking if docker is running every 2 seconds fmt.Println("Waiting for Docker to start...") for range 5 { if isDockerRunning() { fmt.Println("Docker is running!") break } fmt.Println("Docker is not running yet, waiting...") time.Sleep(2 * time.Second) } if !isDockerRunning() { fmt.Println("Docker is still not running after 10 seconds. Please check the installation.") os.Exit(1) } fmt.Println("Docker installed successfully!") } } if err := pullContainers(config.InstallationContainerType); err != nil { fmt.Println("Error: ", err) return } if err := startContainers(config.InstallationContainerType); err != nil { fmt.Println("Error: ", err) return } } } else { alreadyInstalled = true fmt.Println("Looks like you already installed Pangolin!") // Check if MaxMind database exists and offer to update it fmt.Println("\n=== MaxMind Database Update ===") if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil { fmt.Println("MaxMind GeoLite2 Country database found.") if readBool("Would you like to update the MaxMind database to the latest version?", false) { if err := downloadMaxMindDatabase(); err != nil { fmt.Printf("Error updating MaxMind database: %v\n", err) fmt.Println("You can try updating it manually later if needed.") } } } else { fmt.Println("MaxMind GeoLite2 Country database not found.") if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) { if err := downloadMaxMindDatabase(); err != nil { fmt.Printf("Error downloading MaxMind database: %v\n", err) fmt.Println("You can try downloading it manually later if needed.") } // Now you need to update your config file accordingly to enable geoblocking fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n") // add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server fmt.Println("Add the following line under the 'server' section:") fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"") } } } if !checkIsCrowdsecInstalledInCompose() { fmt.Println("\n=== CrowdSec Install ===") // check if crowdsec is installed if readBool("Would you like to install CrowdSec?", false) { fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.") // BUG: crowdsec installation will be skipped if the user chooses to install on the first installation. if readBool("Are you willing to manage CrowdSec?", false) { if config.DashboardDomain == "" { traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml") if err != nil { fmt.Printf("Error reading config: %v\n", err) return } appConfig, err := ReadAppConfig("config/config.yml") if err != nil { fmt.Printf("Error reading config: %v\n", err) return } parsedURL, err := url.Parse(appConfig.DashboardURL) if err != nil { fmt.Printf("Error parsing URL: %v\n", err) return } config.DashboardDomain = parsedURL.Hostname() config.LetsEncryptEmail = traefikConfig.LetsEncryptEmail config.BadgerVersion = traefikConfig.BadgerVersion // print the values and check if they are right fmt.Println("Detected values:") fmt.Printf("Dashboard Domain: %s\n", config.DashboardDomain) fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail) fmt.Printf("Badger Version: %s\n", config.BadgerVersion) if !readBool("Are these values correct?", true) { config = collectUserInput() } } // Try to detect container type from existing installation detectedType := detectContainerType() if detectedType == Undefined { // If detection fails, prompt the user fmt.Println("Unable to detect container type from existing installation.") config.InstallationContainerType = podmanOrDocker() } else { config.InstallationContainerType = detectedType fmt.Printf("Detected container type: %s\n", config.InstallationContainerType) } config.DoCrowdsecInstall = true err := installCrowdsec(config) if err != nil { fmt.Printf("Error installing CrowdSec: %v\n", err) return } fmt.Println("CrowdSec installed successfully!") } } } if !alreadyInstalled || config.DoCrowdsecInstall { // Setup Token Section fmt.Println("\n=== Setup Token ===") // Check if containers were started during this installation containersStarted := false if (isDockerInstalled() && config.InstallationContainerType == Docker) || (isPodmanInstalled() && config.InstallationContainerType == Podman) { // Try to fetch and display the token if containers are running containersStarted = true printSetupToken(config.InstallationContainerType, config.DashboardDomain) } // If containers weren't started or token wasn't found, show instructions if !containersStarted { showSetupTokenInstructions(config.InstallationContainerType, config.DashboardDomain) } } fmt.Println("\nInstallation complete!") fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain) } func podmanOrDocker() SupportedContainer { inputContainer := readString("Would you like to run Pangolin as Docker or Podman containers?", "docker") chosenContainer := Docker if strings.EqualFold(inputContainer, "docker") { chosenContainer = Docker } else if strings.EqualFold(inputContainer, "podman") { chosenContainer = Podman } else { fmt.Printf("Unrecognized container type: %s. Valid options are 'docker' or 'podman'.\n", inputContainer) os.Exit(1) } switch chosenContainer { case Podman: if !isPodmanInstalled() { fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.") os.Exit(1) } if err := exec.Command("bash", "-c", "cat /etc/sysctl.d/99-podman.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start=' || cat /etc/sysctl.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.") fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.") approved := readBool("The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true) if approved { if os.Geteuid() != 0 { fmt.Println("You need to run the installer as root for such a configuration.") os.Exit(1) } // Podman containers are not able to listen on privileged ports. The official recommendation is to // container low-range ports as unprivileged ports. // Linux only. if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system"); err != nil { fmt.Printf("Error configuring unprivileged ports: %v\n", err) os.Exit(1) } } else { fmt.Println("You need to configure port forwarding or adjust the listening ports before running pangolin.") } } else { fmt.Println("Unprivileged ports have been configured.") } case Docker: // check if docker is not installed and the user is root if !isDockerInstalled() { if os.Geteuid() != 0 { fmt.Println("Docker is not installed. Please install Docker manually or run this installer as root.") os.Exit(1) } } // check if the user is in the docker group (linux only) if !isUserInDockerGroup() { fmt.Println("You are not in the docker group.") fmt.Println("The installer will not be able to run docker commands without running it as root.") os.Exit(1) } default: // This shouldn't happen unless there's a third container runtime. os.Exit(1) } return chosenContainer } func collectUserInput() Config { config := Config{} // Basic configuration fmt.Println("\n=== Basic Configuration ===") config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.") config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "") // Set default dashboard domain after base domain is collected defaultDashboardDomain := "" if config.BaseDomain != "" { defaultDashboardDomain = "pangolin." + config.BaseDomain } config.DashboardDomain = readString("Enter the domain for the Pangolin dashboard", defaultDashboardDomain) config.LetsEncryptEmail = readString("Enter email for Let's Encrypt certificates", "") config.InstallGerbil = readBool("Do you want to use Gerbil to allow tunneled connections", true) // Email configuration fmt.Println("\n=== Email Configuration ===") config.EnableEmail = readBool("Enable email functionality (SMTP)", false) if config.EnableEmail { config.EmailSMTPHost = readString("Enter SMTP host", "") config.EmailSMTPPort = readInt("Enter SMTP port (default 587)", 587) config.EmailSMTPUser = readString("Enter SMTP username", "") config.EmailSMTPPass = readPassword("Enter SMTP password") config.EmailNoReply = readString("Enter no-reply email address (often the same as SMTP username)", "") } // Validate required fields if config.BaseDomain == "" { fmt.Println("Error: Domain name is required") os.Exit(1) } if config.LetsEncryptEmail == "" { fmt.Println("Error: Let's Encrypt email is required") os.Exit(1) } if config.EnableEmail && config.EmailNoReply == "" { fmt.Println("Error: No-reply email address is required when email is enabled") os.Exit(1) } // Advanced configuration fmt.Println("\n=== Advanced Configuration ===") config.EnableIPv6 = readBool("Is your server IPv6 capable?", true) config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true) if config.DashboardDomain == "" { fmt.Println("Error: Dashboard Domain name is required") os.Exit(1) } return config } func createConfigFiles(config Config) error { if err := os.MkdirAll("config", 0755); err != nil { return fmt.Errorf("failed to create config directory: %v", err) } if err := os.MkdirAll("config/letsencrypt", 0755); err != nil { return fmt.Errorf("failed to create letsencrypt directory: %v", err) } if err := os.MkdirAll("config/db", 0755); err != nil { return fmt.Errorf("failed to create db directory: %v", err) } if err := os.MkdirAll("config/logs", 0755); err != nil { return fmt.Errorf("failed to create logs directory: %v", err) } // Walk through all embedded files err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } // Skip the root fs directory itself if path == "config" { return nil } if !config.DoCrowdsecInstall && strings.Contains(path, "crowdsec") { return nil } if config.DoCrowdsecInstall && !strings.Contains(path, "crowdsec") { return nil } // skip .DS_Store if strings.Contains(path, ".DS_Store") { return nil } if d.IsDir() { // Create directory if err := os.MkdirAll(path, 0755); err != nil { return fmt.Errorf("failed to create directory %s: %v", path, err) } return nil } // Read the template file content, err := configFiles.ReadFile(path) if err != nil { return fmt.Errorf("failed to read %s: %v", path, err) } // Parse template tmpl, err := template.New(d.Name()).Parse(string(content)) if err != nil { return fmt.Errorf("failed to parse template %s: %v", path, err) } // Ensure parent directory exists if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { return fmt.Errorf("failed to create parent directory for %s: %v", path, err) } // Create output file outFile, err := os.Create(path) if err != nil { return fmt.Errorf("failed to create %s: %v", path, err) } defer outFile.Close() // Execute template if err := tmpl.Execute(outFile, config); err != nil { return fmt.Errorf("failed to execute template %s: %v", path, err) } return nil }) if err != nil { return fmt.Errorf("error walking config files: %v", err) } return nil } func copyFile(src, dst string) error { source, err := os.Open(src) if err != nil { return err } defer source.Close() destination, err := os.Create(dst) if err != nil { return err } defer destination.Close() _, err = io.Copy(destination, source) return err } func moveFile(src, dst string) error { if err := copyFile(src, dst); err != nil { return err } return os.Remove(src) } func printSetupToken(containerType SupportedContainer, dashboardDomain string) { fmt.Println("Waiting for Pangolin to generate setup token...") // Wait for Pangolin to be healthy if err := waitForContainer("pangolin", containerType); err != nil { fmt.Println("Warning: Pangolin container did not become healthy in time.") return } // Give a moment for the setup token to be generated time.Sleep(2 * time.Second) // Fetch logs var cmd *exec.Cmd if containerType == Docker { cmd = exec.Command("docker", "logs", "pangolin") } else { cmd = exec.Command("podman", "logs", "pangolin") } output, err := cmd.Output() if err != nil { fmt.Println("Warning: Could not fetch Pangolin logs to find setup token.") return } // Parse for setup token lines := strings.Split(string(output), "\n") for i, line := range lines { if strings.Contains(line, "=== SETUP TOKEN GENERATED ===") || strings.Contains(line, "=== SETUP TOKEN EXISTS ===") { // Look for "Token: ..." in the next few lines for j := i + 1; j < i+5 && j < len(lines); j++ { trimmedLine := strings.TrimSpace(lines[j]) if strings.Contains(trimmedLine, "Token:") { // Extract token after "Token:" tokenStart := strings.Index(trimmedLine, "Token:") if tokenStart != -1 { token := strings.TrimSpace(trimmedLine[tokenStart+6:]) fmt.Printf("Setup token: %s\n", token) fmt.Println("") fmt.Println("This token is required to register the first admin account in the web UI at:") fmt.Printf("https://%s/auth/initial-setup\n", dashboardDomain) fmt.Println("") fmt.Println("Save this token securely. It will be invalid after the first admin is created.") return } } } } } fmt.Println("Warning: Could not find a setup token in Pangolin logs.") } func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomain string) { fmt.Println("\n=== Setup Token Instructions ===") fmt.Println("To get your setup token, you need to:") fmt.Println("") fmt.Println("1. Start the containers") switch containerType { case Docker: fmt.Println(" docker compose up -d") case Podman: fmt.Println(" podman-compose up -d") } fmt.Println("") fmt.Println("2. Wait for the Pangolin container to start and generate the token") fmt.Println("") fmt.Println("3. Check the container logs for the setup token") switch containerType { case Docker: fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'") case Podman: fmt.Println(" podman logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'") } fmt.Println("") fmt.Println("4. Look for output like") fmt.Println(" === SETUP TOKEN GENERATED ===") fmt.Println(" Token: [your-token-here]") fmt.Println(" Use this token on the initial setup page") fmt.Println("") fmt.Println("5. Use the token to complete initial setup at") fmt.Printf(" https://%s/auth/initial-setup\n", dashboardDomain) fmt.Println("") fmt.Println("The setup token is required to register the first admin account.") fmt.Println("Save it securely - it will be invalid after the first admin is created.") fmt.Println("================================") } func generateRandomSecretKey() string { secret := make([]byte, 32) _, err := rand.Read(secret) if err != nil { panic(fmt.Sprintf("Failed to generate random secret key: %v", err)) } return base64.StdEncoding.EncodeToString(secret) } func getPublicIP() string { client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Get("https://ifconfig.io/ip") if err != nil { return "" } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "" } ip := strings.TrimSpace(string(body)) // Validate that it's a valid IP address if net.ParseIP(ip) != nil { return ip } return "" } // Run external commands with stdio/stderr attached. func run(name string, args ...string) error { cmd := exec.Command(name, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } func checkPortsAvailable(port int) error { addr := fmt.Sprintf(":%d", port) ln, err := net.Listen("tcp", addr) if err != nil { return fmt.Errorf("ERROR: port %d is occupied or cannot be bound: %w", port, err) } if closeErr := ln.Close(); closeErr != nil { fmt.Fprintf(os.Stderr, "WARNING: failed to close test listener on port %d: %v\n", port, closeErr, ) } return nil } func downloadMaxMindDatabase() error { fmt.Println("Downloading MaxMind GeoLite2 Country database...") // Download the GeoLite2 Country database if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz", "https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil { return fmt.Errorf("failed to download GeoLite2 database: %v", err) } // Extract the database if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil { return fmt.Errorf("failed to extract GeoLite2 database: %v", err) } // Find the .mmdb file and move it to the config directory if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil { return fmt.Errorf("failed to move GeoLite2 database to config directory: %v", err) } // Clean up the downloaded files if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil { fmt.Printf("Warning: failed to clean up temporary files: %v\n", err) } fmt.Println("MaxMind GeoLite2 Country database downloaded successfully!") return nil } ================================================ FILE: install/theme.go ================================================ package main import ( "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" ) // Pangolin brand colors (converted from oklch to hex) var ( // Primary orange/amber - oklch(0.6717 0.1946 41.93) primaryColor = lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"} // Muted foreground mutedColor = lipgloss.AdaptiveColor{Light: "#737373", Dark: "#A3A3A3"} // Success green successColor = lipgloss.AdaptiveColor{Light: "#16A34A", Dark: "#22C55E"} // Error red - oklch(0.577 0.245 27.325) errorColor = lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#EF4444"} // Normal text normalFg = lipgloss.AdaptiveColor{Light: "#171717", Dark: "#FAFAFA"} ) // ThemePangolin returns a huh theme using Pangolin brand colors func ThemePangolin() *huh.Theme { t := huh.ThemeBase() // Focused state styles t.Focused.Base = t.Focused.Base.BorderForeground(primaryColor) t.Focused.Title = t.Focused.Title.Foreground(primaryColor).Bold(true) t.Focused.Description = t.Focused.Description.Foreground(mutedColor) t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errorColor) t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errorColor) t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(primaryColor) t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(primaryColor) t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(primaryColor) t.Focused.Option = t.Focused.Option.Foreground(normalFg) t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(primaryColor) t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(successColor).SetString("✓ ") t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(mutedColor).SetString(" ") t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(primaryColor) t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(normalFg).Background(lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#404040"}) t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(primaryColor) t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(primaryColor) // Blurred state inherits from focused but with hidden border t.Blurred = t.Focused t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) t.Blurred.Title = t.Blurred.Title.Foreground(mutedColor).Bold(false) t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(mutedColor) return t } ================================================ FILE: messages/bg-BG.json ================================================ { "setupCreate": "Създайте организацията, сайта и ресурсите", "headerAuthCompatibilityInfo": "Активирайте това, за да принудите отговор '401 Неупълномощено', когато липсва токен за автентификация. Това е необходимо за браузъри или специфични HTTP библиотеки, които не изпращат идентификационни данни без сървърно предизвикателство.", "headerAuthCompatibility": "Разширена съвместимост.", "setupNewOrg": "Нова организация", "setupCreateOrg": "Създаване на организация", "setupCreateResources": "Създаване на ресурси", "setupOrgName": "Име на организацията", "orgDisplayName": "Това е публичното име на организацията.", "orgId": "Идентификатор на организация", "setupIdentifierMessage": "Това е уникалният идентификатор за организацията.", "setupErrorIdentifier": "Идентификаторът на организация вече е зает. Моля, изберете друг.", "componentsErrorNoMemberCreate": "В момента не сте част от организация. Създайте организация, за да продължите.", "componentsErrorNoMember": "В момента не сте част от организация.", "welcome": "Добре дошли!", "welcomeTo": "Добре дошли в", "componentsCreateOrg": "Създаване на организация", "componentsMember": "Вие сте част от {count, plural, =0 {нула организации} one {една организация} other {# организации}}.", "componentsInvalidKey": "Засечен е невалиден или изтекъл лиценз. Проверете лицензионните условия, за да се възползвате от всички функционалности.", "dismiss": "Отхвърляне", "subscriptionViolationMessage": "Превишихте ограничението на текущия си план. Коригирайте проблема, като премахнете сайтове, потребители или други ресурси, за да оставате в рамките на плана си.", "subscriptionViolationViewBilling": "Преглед на фактурирането", "componentsLicenseViolation": "Нарушение на лиценза: Сървърът използва {usedSites} сайта, което надвишава лицензионния лимит от {maxSites} сайта. Проверете лицензионните условия, за да се възползвате от всички функционалности.", "componentsSupporterMessage": "Благодарим ви, че подкрепяте Pangolin като {tier}!", "inviteErrorNotValid": "Съжаляваме, но изглежда, че поканата, до която се опитвате да получите достъп, не е приета или вече не е валидна.", "inviteErrorUser": "Съжаляваме, но изглежда, че поканата, до която се опитвате да получите достъп, не е предназначена за този потребител.", "inviteLoginUser": "Моля, уверете се, че сте влезли като правилния потребител.", "inviteErrorNoUser": "Съжаляваме, но изглежда, че поканата, до която се опитвате да получите достъп, не е за съществуващ потребител.", "inviteCreateUser": "Моля, първо създайте акаунт.", "goHome": "Отиди вкъщи", "inviteLogInOtherUser": "Влезте като друг потребител", "createAnAccount": "Създайте профил", "inviteNotAccepted": "Поканата не е приета", "authCreateAccount": "Създайте акаунт, за да започнете", "authNoAccount": "Нямате акаунт?", "email": "Имейл", "password": "Парола", "confirmPassword": "Потвърждение на паролата", "createAccount": "Създаване на профил", "viewSettings": "Преглед на настройките.", "delete": "Изтриване", "name": "Име", "online": "На линия", "offline": "Извън линия", "site": "Сайт", "dataIn": "Входящ трафик", "dataOut": "Изходящ трафик", "connectionType": "Вид на връзката", "tunnelType": "Вид на тунела", "local": "Локална", "edit": "Редактиране", "siteConfirmDelete": "Потвърждение на изтриване на сайта", "siteDelete": "Изтриване на сайта", "siteMessageRemove": "След премахване, сайтът вече няма да бъде достъпен. Всички цели, свързани със сайта, също ще бъдат премахнати.", "siteQuestionRemove": "Сигурни ли сте, че искате да премахнете сайта от организацията?", "siteManageSites": "Управление на сайтове", "siteDescription": "Създайте и управлявайте сайтове, за да осигурите свързаност със частни мрежи", "sitesBannerTitle": "Свържете се с мрежа.", "sitesBannerDescription": "Сайтът е връзка с отдалечена мрежа, която позволява на Pangolin да предоставя достъп до ресурси, било то публични или частни, на потребители навсякъде. Инсталирайте мрежовия конектор на сайта (Newt) навсякъде, където можете да стартирате бинарен или контейнер, за да създадете връзката.", "sitesBannerButtonText": "Инсталиране на сайт.", "approvalsBannerTitle": "Одобрете или откажете достъп до устройство", "approvalsBannerDescription": "Прегледайте и одобрите или откажете искания за достъп до устройства от потребители. Когато се изисква одобрение на устройства, потребителите трябва да получат администраторско одобрение, преди техните устройства да могат да се свържат с ресурсите на вашата организация.", "approvalsBannerButtonText": "Научете повече", "siteCreate": "Създайте сайт", "siteCreateDescription2": "Следвайте стъпките по-долу, за да създадете и свържете нов сайт", "siteCreateDescription": "Създайте нов сайт, за да започнете да свързвате ресурси", "close": "Затвори", "siteErrorCreate": "Грешка при създаване на сайт", "siteErrorCreateKeyPair": "Ключова двойка или настройки по подразбиране на сайта не са намерени", "siteErrorCreateDefaults": "Настройки по подразбиране на сайта не са намерени", "method": "Метод", "siteMethodDescription": "Това е как ще се изложат свързванията.", "siteLearnNewt": "Научете как да инсталирате Newt на вашата система", "siteSeeConfigOnce": "Ще можете да видите конфигурацията само веднъж.", "siteLoadWGConfig": "Зареждане на WireGuard конфигурация...", "siteDocker": "Разширете за детайли относно внедряване с Docker", "toggle": "Превключване", "dockerCompose": "Docker Compose", "dockerRun": "Docker Run", "siteLearnLocal": "Локалните сайтове не тунелират, научете повече", "siteConfirmCopy": "Копирах конфигурацията", "searchSitesProgress": "Търсене на сайтове...", "siteAdd": "Добавете сайт", "siteInstallNewt": "Инсталирайте Newt", "siteInstallNewtDescription": "Пуснете Newt на вашата система", "WgConfiguration": "WireGuard конфигурация", "WgConfigurationDescription": "Използвайте следната конфигурация, за да се свържете с мрежата", "operatingSystem": "Операционна система", "commands": "Команди", "recommended": "Препоръчано", "siteNewtDescription": "За най-добро потребителско преживяване, използвайте Newt. Това е WireoGuard под повърхността и ви позволява да осъществявате достъп до личните си ресурси чрез LAN адреса им от вашия частен Pangolin дашборд.", "siteRunsInDocker": "Работи в Docker", "siteRunsInShell": "Работи в обвивка на macOS, Linux и Windows", "siteErrorDelete": "Грешка при изтриване на сайта", "siteErrorUpdate": "Неуспешно актуализиране на сайта", "siteErrorUpdateDescription": "Възникна грешка при актуализирането на сайта.", "siteUpdated": "Сайтът е обновен", "siteUpdatedDescription": "Сайтът е актуализиран.", "siteGeneralDescription": "Конфигурирайте общи настройки за този сайт", "siteSettingDescription": "Конфигурирайте настройките на сайта", "siteSetting": "Настройки на {siteName}", "siteNewtTunnel": "Нов Сайт (Препоръчително)", "siteNewtTunnelDescription": "Най-лесният начин да създадете точка за достъп до всяка мрежа. Няма нужда от допълнителни настройки.", "siteWg": "Основен WireGuard", "siteWgDescription": "Use any WireGuard client to establish a tunnel. Manual NAT setup required. ONLY WORKS ON SELF HOSTED NODES", "siteWgDescriptionSaas": "Използвайте всеки WireGuard клиент за установяване на тунел. Ръчно нат задаване е необходимо. РАБОТИ САМО НА СОБСТВЕНИ УЗЛИ.", "siteLocalDescription": "Local resources only. No tunneling. ONLY WORKS ON SELF HOSTED NODES", "siteLocalDescriptionSaas": "Само локални ресурси. Без тунелиране. Достъпно само на отдалечени възли.", "siteSeeAll": "Вижте всички сайтове", "siteTunnelDescription": "Определете как искате да се свържете със сайта", "siteNewtCredentials": "Пълномощия", "siteNewtCredentialsDescription": "Това е как сайтът ще се удостоверява с сървъра", "remoteNodeCredentialsDescription": "Това е начинът, по който отдалеченият възел ще се автентифицира със сървъра.", "siteCredentialsSave": "Запазете Пълномощията", "siteCredentialsSaveDescription": "Ще можете да виждате това само веднъж. Уверете се да го копирате на сигурно място.", "siteInfo": "Информация за сайта", "status": "Статус", "shareTitle": "Управление на връзки за споделяне", "shareDescription": "Създайте споделими връзки, за да предоставите временен или постоянен достъп до прокси ресурсите", "shareSearch": "Търсене на връзки за споделяне...", "shareCreate": "Създайте връзка за споделяне", "shareErrorDelete": "Неуспешно изтриване на връзката", "shareErrorDeleteMessage": "Възникна грешка при изтриване на връзката", "shareDeleted": "Връзката беше изтрита", "shareDeletedDescription": "Връзката беше премахната", "shareTokenDescription": "Достъпният токен може да бъде предаван по два начина: като параметър или в хедърите на заявките. Те трябва да бъдат предавани от клиента при всяка заявка за удостоверен достъп.", "accessToken": "Достъп Токен", "usageExamples": "Примери за използване", "tokenId": "Токен ID", "requestHeades": "Заглавие на заявката", "queryParameter": "Параметър за заявка", "importantNote": "Важно бележка", "shareImportantDescription": "По съображения за сигурност, използването на заглавки се препоръчва пред параметри на заявка, когато е възможно, тъй като параметри на заявка могат да бъдат записвани в логове на сървъра или в историята на браузъра.", "token": "Токен", "shareTokenSecurety": "Запазете сигурността на токена за достъп. Не го споделяйте в общодостъпни зони или в клиентски код.", "shareErrorFetchResource": "Неуспешно вземане на ресурси", "shareErrorFetchResourceDescription": "Възникна грешка при опит за вземане на ресурсите", "shareErrorCreate": "Неуспешно създаване на връзка за споделяне", "shareErrorCreateDescription": "Възникна грешка при създаването на връзката за споделяне", "shareCreateDescription": "Всеки с тази връзка може да получи достъп до ресурса", "shareTitleOptional": "Заглавие (по избор)", "expireIn": "Изтече", "neverExpire": "Никога не изтича", "shareExpireDescription": "Времето на изтичане е колко дълго връзката ще бъде използваема и ще предоставя достъп до ресурса. След това време, връзката няма да работи и потребителите, които са я използвали, ще загубят достъп до ресурса.", "shareSeeOnce": "Ще можете да видите този линк само веднъж. Уверете се, че го копирате.", "shareAccessHint": "Всеки с тази връзка може да има достъп до ресурса. Споделяйте я с внимание.", "shareTokenUsage": "Вижте използването на токена за достъп", "createLink": "Създаване на връзка", "resourcesNotFound": "Не са намерени ресурси", "resourceSearch": "Търсене на ресурси", "openMenu": "Отваряне на менюто", "resource": "Ресурс", "title": "Заглавие", "created": "Създадено", "expires": "Изтича", "never": "Никога", "shareErrorSelectResource": "Моля, изберете ресурс", "proxyResourceTitle": "Управление на обществени ресурси", "proxyResourceDescription": "Създайте и управлявайте ресурси, които са общодостъпни чрез уеб браузър.", "proxyResourcesBannerTitle": "Публичен достъп чрез уеб.", "proxyResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.", "clientResourceTitle": "Управление на частни ресурси", "clientResourceDescription": "Създайте и управлявайте ресурси, които са достъпни само чрез свързан клиент.", "privateResourcesBannerTitle": "Достъп до частни ресурси с нулево доверие.", "privateResourcesBannerDescription": "Частните ресурси използват сигурност с нулево доверие, осигурявайки че потребителите и машините могат да имат само достъп до ресурси, които вие изрично предоставяте. Свържете потребителските устройства или машинните клиенти, за да получите достъп до тези ресурси чрез сигурна виртуална частна мрежа.", "resourcesSearch": "Търсене на ресурси...", "resourceAdd": "Добавете ресурс", "resourceErrorDelte": "Грешка при изтриване на ресурс", "authentication": "Удостоверяване", "protected": "Защита", "notProtected": "Не защитен", "resourceMessageRemove": "След като се премахне, ресурсът няма повече да бъде достъпен. Всички цели, свързани с ресурса, също ще бъдат премахнати.", "resourceQuestionRemove": "Сигурни ли сте, че искате да премахнете ресурса от организацията?", "resourceHTTP": "HTTPS ресурс", "resourceHTTPDescription": "Прокси заявки чрез HTTPS, използвайки напълно квалифицирано име на домейн.", "resourceRaw": "Суров TCP/UDP ресурс", "resourceRawDescription": "Прокси заявки чрез сурови TCP/UDP, използвайки порт номер.", "resourceRawDescriptionCloud": "Прокси заявките през суров TCP/UDP, използвайки номер на порт. ИЗИСКВА ИЗПОЛЗВАНЕ НА ОТДАЛЕЧЕН УЗЕЛ.", "resourceCreate": "Създайте ресурс", "resourceCreateDescription": "Следвайте стъпките по-долу, за да създадете нов ресурс", "resourceSeeAll": "Вижте всички ресурси", "resourceInfo": "Информация за ресурса", "resourceNameDescription": "Това е дисплейното име на ресурса.", "siteSelect": "Изберете сайт", "siteSearch": "Търсене на сайт", "siteNotFound": "Няма намерени сайтове.", "selectCountry": "Изберете държава", "searchCountries": "Търсене на държави...", "noCountryFound": "Не е намерена държава.", "siteSelectionDescription": "Този сайт ще осигури свързаност до целта.", "resourceType": "Тип ресурс", "resourceTypeDescription": "Определете как да се достъпи ресурсът", "resourceHTTPSSettings": "HTTPS настройки", "resourceHTTPSSettingsDescription": "Конфигурирайте как ресурсът ще бъде достъпен по HTTPS", "domainType": "Тип домейн", "subdomain": "Субдомейн", "baseDomain": "Базов домейн", "subdomnainDescription": "Поддомейнът, където ресурсът ще бъде достъпен.", "resourceRawSettings": "TCP/UDP настройки", "resourceRawSettingsDescription": "Конфигурирайте как ресурсът ще бъде достъпен чрез TCP/UDP", "protocol": "Протокол", "protocolSelect": "Изберете протокол", "resourcePortNumber": "Номер на порт", "resourcePortNumberDescription": "Външен номер на порт за прокси заявки.", "back": "Назад", "cancel": "Отмяна", "resourceConfig": "Конфигурационни фрагменти", "resourceConfigDescription": "Копирайте и поставете тези конфигурационни отрязъци, за да настроите TCP/UDP ресурса", "resourceAddEntrypoints": "Traefik: Добавете Входни точки", "resourceExposePorts": "Gerbil: Изложете портове в Docker Compose", "resourceLearnRaw": "Научете как да конфигурирате TCP/UDP ресурси", "resourceBack": "Назад към ресурсите", "resourceGoTo": "Отидете към ресурса", "resourceDelete": "Изтрийте ресурс", "resourceDeleteConfirm": "Потвърдете изтриване на ресурс", "visibility": "Видимост", "enabled": "Активиран", "disabled": "Деактивиран", "general": "Общи", "generalSettings": "Общи настройки", "proxy": "Прокси", "internal": "Вътрешен", "rules": "Правила", "resourceSettingDescription": "Конфигурирайте настройките на ресурса", "resourceSetting": "Настройки на {resourceName}", "alwaysAllow": "Заобикаляне на Ауторизацията", "alwaysDeny": "Блокиране на Достъпа", "passToAuth": "Прехвърляне към удостоверяване", "orgSettingsDescription": "Конфигурирайте настройките на организацията", "orgGeneralSettings": "Настройки на организацията", "orgGeneralSettingsDescription": "Управлявайте детайлите и конфигурацията на организацията", "saveGeneralSettings": "Запазете общите настройки", "saveSettings": "Запазване на настройките", "orgDangerZone": "Опасна зона", "orgDangerZoneDescription": "След като изтриете тази организация, няма връщане назад. Моля, бъдете сигурен.", "orgDelete": "Изтрийте организацията", "orgDeleteConfirm": "Потвърдете изтриване на организация", "orgMessageRemove": "Това действие е необратимо и ще изтрие всички свързани данни.", "orgMessageConfirm": "За потвърждение, моля, напишете името на организацията по-долу.", "orgQuestionRemove": "Сигурни ли сте, че искате да премахнете организацията?", "orgUpdated": "Организацията е актуализирана", "orgUpdatedDescription": "Организацията е обновена.", "orgErrorUpdate": "Неуспешно актуализиране на организацията", "orgErrorUpdateMessage": "Възникна грешка при актуализиране на организацията.", "orgErrorFetch": "Неуспешно вземане на организации", "orgErrorFetchMessage": "Възникна грешка при изброяване на вашите организации", "orgErrorDelete": "Неуспешно изтриване на организацията", "orgErrorDeleteMessage": "Възникна грешка при изтриването на организацията.", "orgDeleted": "Организацията е изтрита", "orgDeletedMessage": "Организацията и нейните данни са изтрити.", "deleteAccount": "Изтриване на профил", "deleteAccountDescription": "Перманентно изтрийте своя профил, всички организации, които притежавате, и всички данни в тези организации. Това не може да бъде отменено.", "deleteAccountButton": "Изтриване на профил", "deleteAccountConfirmTitle": "Изтрий профила", "deleteAccountConfirmMessage": "Това ще изтрие перманентно вашия профил, всички организации, които притежавате, и всички данни в тези организации. Това не може да бъде отменено.", "deleteAccountConfirmString": "изтриване на профил", "deleteAccountSuccess": "Профилът е изтрит", "deleteAccountSuccessMessage": "Вашият профил е изтрит.", "deleteAccountError": "Неуспешно изтриване на профил", "deleteAccountPreviewAccount": "Вашият профил", "deleteAccountPreviewOrgs": "Организации, които притежавате (и всички техни данни)", "orgMissing": "Липсва идентификатор на организация", "orgMissingMessage": "Невъзможност за регенериране на покана без идентификатор на организация.", "accessUsersManage": "Управление на потребители", "accessUsersDescription": "Канете и управлявайте потребители с достъп до тази организация", "accessUsersSearch": "Търсене на потребители...", "accessUserCreate": "Създайте потребител", "accessUserRemove": "Премахнете потребител", "username": "Потребителско име", "identityProvider": "Доставчик на идентичност", "role": "Роля", "nameRequired": "Името е задължително", "accessRolesManage": "Управление на роли", "accessRolesDescription": "Създайте и управлявайте роли за потребители в организацията", "accessRolesSearch": "Търсене на роли...", "accessRolesAdd": "Добавете роля", "accessRoleDelete": "Изтриване на роля", "accessApprovalsManage": "Управление на одобрения", "accessApprovalsDescription": "Прегледайте и управлявайте чакащи одобрения за достъп до тази организация", "description": "Описание", "inviteTitle": "Отворени покани", "inviteDescription": "Управлявайте покани за други потребители да се присъединят към организацията", "inviteSearch": "Търсене на покани...", "minutes": "Минути", "hours": "Часове", "days": "Дни", "weeks": "Седмици", "months": "Месеци", "years": "Години", "day": "{count, plural, one {# ден} other {# дни}}", "apiKeysTitle": "Информация за API ключ", "apiKeysConfirmCopy2": "Трябва да потвърдите, че сте копирали API ключът.", "apiKeysErrorCreate": "Грешка при създаване на API ключ", "apiKeysErrorSetPermission": "Грешка при задаване на разрешения", "apiKeysCreate": "Генерирайте API ключ", "apiKeysCreateDescription": "Създайте нов API ключ за организацията", "apiKeysGeneralSettings": "Разрешения", "apiKeysGeneralSettingsDescription": "Определете какво може да прави този API ключ", "apiKeysList": "Нов API Ключ", "apiKeysSave": "Запазете API Ключа", "apiKeysSaveDescription": "Ще можете да виждате това само веднъж. Уверете се да го копирате на сигурно място.", "apiKeysInfo": "API ключът е:", "apiKeysConfirmCopy": "Копирах API ключа", "generate": "Генериране", "done": "Готово", "apiKeysSeeAll": "Вижте всички API ключове", "apiKeysPermissionsErrorLoadingActions": "Грешка при зареждане на действията на API ключа", "apiKeysPermissionsErrorUpdate": "Грешка при задаване на разрешения", "apiKeysPermissionsUpdated": "Разрешенията са актуализирани", "apiKeysPermissionsUpdatedDescription": "Разрешенията са обновени.", "apiKeysPermissionsGeneralSettings": "Разрешения", "apiKeysPermissionsGeneralSettingsDescription": "Определете какво може да прави този API ключ", "apiKeysPermissionsSave": "Запазете разрешенията", "apiKeysPermissionsTitle": "Разрешения", "apiKeys": "API ключове", "searchApiKeys": "Търсене на API ключове...", "apiKeysAdd": "Генерирайте API ключ", "apiKeysErrorDelete": "Грешка при изтриване на API ключ", "apiKeysErrorDeleteMessage": "Грешка при изтриване на API ключ", "apiKeysQuestionRemove": "Сигурни ли сте, че искате да премахнете API ключа от организацията?", "apiKeysMessageRemove": "След като бъде премахнат, API ключът няма вече да може да се използва.", "apiKeysDeleteConfirm": "Потвърдете изтриване на API ключ", "apiKeysDelete": "Изтрийте API ключа", "apiKeysManage": "Управление на API ключове", "apiKeysDescription": "API ключове се използват за удостоверяване с интеграционния API", "apiKeysSettings": "Настройки на {apiKeyName}", "userTitle": "Управление на всички потребители", "userDescription": "Преглед и управление на всички потребители в системата", "userAbount": "Относно управлението на потребители", "userAbountDescription": "Тази таблица показва всички рут потребителски обекти в системата. Всеки потребител може да принадлежи към множество организации. Изтриването на потребител от организация не премахва неговия рут потребителски обект - той ще остане в системата. За да премахнете напълно потребител от системата, трябва да изтриете неговия рут потребителски обект чрез действие за изтриване в тази таблица.", "userServer": "Сървърни потребители", "userSearch": "Търсене на сървърни потребители...", "userErrorDelete": "Грешка при изтриване на потребител", "userDeleteConfirm": "Потвърдете изтриването на потребител", "userDeleteServer": "Изтрийте потребителя от сървъра", "userMessageRemove": "Потребителят ще бъде премахнат от всички организации и напълно заличен от сървъра.", "userQuestionRemove": "Сигурни ли сте, че искате да изтриете потребител от сървъра?", "licenseKey": "Ключ за лиценз", "valid": "Валиден", "numberOfSites": "Брой сайтове", "licenseKeySearch": "Търсене на лицензионни ключове...", "licenseKeyAdd": "Добавете лицензионен ключ", "type": "Тип", "licenseKeyRequired": "Необходим е лицензионен ключ", "licenseTermsAgree": "Трябва да се съгласите с лицензионните условия", "licenseErrorKeyLoad": "Неуспешно зареждане на лицензионни ключове", "licenseErrorKeyLoadDescription": "Възникна грешка при зареждане на лицензионните ключове.", "licenseErrorKeyDelete": "Неуспешно изтриване на лицензионен ключ", "licenseErrorKeyDeleteDescription": "Възникна грешка при изтриване на лицензионния ключ.", "licenseKeyDeleted": "Лицензионният ключ е изтрит", "licenseKeyDeletedDescription": "Лицензионният ключ беше изтрит.", "licenseErrorKeyActivate": "Неуспешно активиране на лицензионния ключ", "licenseErrorKeyActivateDescription": "Възникна грешка при активирането на лицензионния ключ.", "licenseAbout": "Относно лицензите", "communityEdition": "Комюнити издание", "licenseAboutDescription": "Това е за бизнес и корпоративни потребители, които използват Pangolin в търговска среда. Ако използвате Pangolin за лична употреба, можете да игнорирате този раздел.", "licenseKeyActivated": "Лицензионният ключ е активиран", "licenseKeyActivatedDescription": "Лицензионният ключ беше успешно активиран.", "licenseErrorKeyRecheck": "Неуспешно повторно проверяване на лицензионните ключове", "licenseErrorKeyRecheckDescription": "Възникна грешка при повторно проверяване на лицензионните ключове.", "licenseErrorKeyRechecked": "Лицензионните ключове бяха повторно проверени", "licenseErrorKeyRecheckedDescription": "Всички лицензионни ключове бяха повторно проверени", "licenseActivateKey": "Активиране на лицензионен ключ", "licenseActivateKeyDescription": "Въведете лицензионен ключ, за да го активирате.", "licenseActivate": "Активиране на лицензията", "licenseAgreement": "Чрез поставяне на отметка в това поле потвърждавате, че сте прочели и се съгласявате с лицензионните условия, съответстващи на нивото, свързано с Вашия лицензионен ключ.", "fossorialLicense": "Преглед на търговски условия и абонамент за Fossorial", "licenseMessageRemove": "Това ще премахне лицензионния ключ и всички свързани права, предоставени от него.", "licenseMessageConfirm": "За да потвърдите, въведете лицензионния ключ по-долу.", "licenseQuestionRemove": "Сигурни ли сте, че искате да премахнете лицензионния ключ?", "licenseKeyDelete": "Изтриване на лицензионен ключ", "licenseKeyDeleteConfirm": "Потвърдете изтриването на лицензионен ключ", "licenseTitle": "Управление на лицензионния статус", "licenseTitleDescription": "Преглед и управление на лицензионни ключове в системата", "licenseHost": "Лиценз за хост", "licenseHostDescription": "Управление на главния лицензионен ключ за хоста.", "licensedNot": "Не е лицензиран", "hostId": "Идентификатор на хост", "licenseReckeckAll": "Повторно проверяване на всички ключове", "licenseSiteUsage": "Използване на сайтове", "licenseSiteUsageDecsription": "Преглед на броя на сайтовете, които използват този лиценз.", "licenseNoSiteLimit": "Няма лимит за броя на сайтовете, използващи нелицензиран хост.", "licensePurchase": "Закупуване на лиценз", "licensePurchaseSites": "Закупуване на допълнителни сайтове", "licenseSitesUsedMax": "{usedSites} от {maxSites} сайтове използвани", "licenseSitesUsed": "{count, plural, =0 {# сайта} one {# сайт} other {# сайта}} в системата.", "licensePurchaseDescription": "Изберете колко сайтове искате да {selectedMode, select, license {закупите лиценз за. Можете винаги да добавите повече сайтове по-късно.} other {добавите към съществуващия си лиценз.}}", "licenseFee": "Такса за лиценз", "licensePriceSite": "Цена на сайт", "total": "Общо", "licenseContinuePayment": "Продължете към плащане", "pricingPage": "страница с цени", "pricingPortal": "Преглед на портала за закупуване", "licensePricingPage": "За най-актуални цени и отстъпки, моля, посетете ", "invite": "Покани", "inviteRegenerate": "Регениране на покана", "inviteRegenerateDescription": "Отменете предишната покана и създайте нова", "inviteRemove": "Премахване на покана", "inviteRemoveError": "Неуспешно премахване на покана", "inviteRemoveErrorDescription": "Възникна грешка при премахване на поканата.", "inviteRemoved": "Поканата е премахната", "inviteRemovedDescription": "Поканата за {имейл} е премахната.", "inviteQuestionRemove": "Сигурни ли сте, че искате да премахнете поканата?", "inviteMessageRemove": "След като бъде премахната, тази покана няма да е валидна. Винаги можете да поканите потребителя отново по-късно.", "inviteMessageConfirm": "За да потвърдите, въведете имейл адреса на поканата по-долу.", "inviteQuestionRegenerate": "Сигурни ли сте, че искате да регенерирате поканата за {email}? Това ще отмени предишната покана.", "inviteRemoveConfirm": "Потвърждение на премахването на поканата", "inviteRegenerated": "Поканата е регенерирана", "inviteSent": "Нова покана е изпратена на {email}.", "inviteSentEmail": "Изпращане на имейл известие до потребителя", "inviteGenerate": "Нова покана е генерирана за {email}.", "inviteDuplicateError": "Дублиране на покана", "inviteDuplicateErrorDescription": "Покана за този потребител вече съществува.", "inviteRateLimitError": "Лимитът на регенерации е надвишен", "inviteRateLimitErrorDescription": "Надвишили сте лимита от 3 регенерации на час. Моля, опитайте отново по-късно.", "inviteRegenerateError": "Неуспешно регениране на поканата", "inviteRegenerateErrorDescription": "Възникна грешка при регенирането на поканата.", "inviteValidityPeriod": "Период на валидност", "inviteValidityPeriodSelect": "Изберете период на валидност", "inviteRegenerateMessage": "Поканата е регенерирана. Потребителят трябва да достъпи линка по-долу, за да приеме поканата.", "inviteRegenerateButton": "Регениране", "expiresAt": "Изтича на", "accessRoleUnknown": "Непозната роля", "placeholder": "Запълване", "userErrorOrgRemove": "Неуспешно премахване на потребител", "userErrorOrgRemoveDescription": "Възникна грешка при премахване на потребителя.", "userOrgRemoved": "Потребителят е премахнат", "userOrgRemovedDescription": "Потребителят {email} беше премахнат от организацията.", "userQuestionOrgRemove": "Сигурни ли сте, че искате да премахнете този потребител от организацията?", "userMessageOrgRemove": "След като бъде премахнат, този потребител няма да има достъп до организацията. Винаги можете да го поканите отново по-късно, но той ще трябва да приеме отново поканата.", "userRemoveOrgConfirm": "Потвърдете премахването на потребителя", "userRemoveOrg": "Премахване на потребителя от организацията", "users": "Потребители", "accessRoleMember": "Член", "accessRoleOwner": "Собственик", "userConfirmed": "Потвърдено", "idpNameInternal": "Вътрешен", "emailInvalid": "Невалиден имейл адрес", "inviteValidityDuration": "Моля, изберете продължителност", "accessRoleSelectPlease": "Моля, изберете роля", "usernameRequired": "Необходимо е потребителско име", "idpSelectPlease": "Моля, изберете доставчик на идентичност", "idpGenericOidc": "Основен OAuth2/OIDC доставчик.", "accessRoleErrorFetch": "Неуспешно извличане на роли", "accessRoleErrorFetchDescription": "Възникна грешка при извличане на ролите", "idpErrorFetch": "Неуспешно извличане на доставчици на идентичност", "idpErrorFetchDescription": "Възникна грешка при извличане на доставчиците на идентичност", "userErrorExists": "Потребителят вече съществува", "userErrorExistsDescription": "Този потребител вече е член на организацията.", "inviteError": "Неуспешно поканване на потребител", "inviteErrorDescription": "Възникна грешка при поканването на потребителя", "userInvited": "Потребителят е поканен.", "userInvitedDescription": "Потребителят беше успешно поканен.", "userErrorCreate": "Неуспешно създаване на потребител", "userErrorCreateDescription": "Възникна грешка при създаване на потребителя", "userCreated": "Потребителят е създаден", "userCreatedDescription": "Потребителят беше успешно създаден.", "userTypeInternal": "Вътрешен потребител", "userTypeInternalDescription": "Поканете потребител да се присъедини директно към организацията.", "userTypeExternal": "Външен потребител", "userTypeExternalDescription": "Създайте потребител с външен доставчик на идентичност.", "accessUserCreateDescription": "Следвайте стъпките по-долу, за да създадете нов потребител", "userSeeAll": "Виж всички потребители", "userTypeTitle": "Тип потребител", "userTypeDescription": "Определете как искате да създадете потребителя", "userSettings": "Информация за потребителя", "userSettingsDescription": "Въведете данните за новия потребител", "inviteEmailSent": "Изпратете покана по имейл до потребителя", "inviteValid": "Валидна за", "selectDuration": "Изберете продължителност", "selectResource": "Изберете Ресурс", "filterByResource": "Филтрирай По Ресурс", "selectApprovalState": "Изберете състояние на одобрение", "filterByApprovalState": "Филтрирайте по състояние на одобрение", "approvalListEmpty": "Няма одобрения", "approvalState": "Състояние на одобрение", "approvalLoadMore": "Заредете още", "loadingApprovals": "Зарежда се одобрение", "approve": "Одобряване", "approved": "Одобрен", "denied": "Отказан", "deniedApproval": "Одобрение е отказано", "all": "Всички", "deny": "Откажете", "viewDetails": "Разгледай подробности", "requestingNewDeviceApproval": "поискана нова устройство", "resetFilters": "Нулиране на Филтрите", "totalBlocked": "Заявки Блокирани От Pangolin", "totalRequests": "Общо Заявки", "requestsByCountry": "Заявки По Държава", "requestsByDay": "Заявки По Ден", "blocked": "Блокирани", "allowed": "Позволени", "topCountries": "Топ Държави", "accessRoleSelect": "Изберете роля", "inviteEmailSentDescription": "Имейлът е изпратен до потребителя с достъпния линк по-долу. Те трябва да достъпят линка, за да приемат поканата.", "inviteSentDescription": "Потребителят е поканен. Те трябва да достъпят линка по-долу, за да приемат поканата.", "inviteExpiresIn": "Поканата ще изтече след {days, plural, one {# ден} other {# дни}}.", "idpTitle": "Доставчик на идентичност", "idpSelect": "Изберете доставчика на идентичност за външния потребител", "idpNotConfigured": "Няма конфигурирани доставчици на идентичност. Моля, конфигурирайте доставчик на идентичност, преди да създавате външни потребители.", "usernameUniq": "Това трябва да съответства на уникалното потребителско име, което съществува във избрания доставчик на идентичност.", "emailOptional": "Имейл (по избор)", "nameOptional": "Име (по избор)", "accessControls": "Контрол на достъпа", "userDescription2": "Управление на настройките на този потребител", "accessRoleErrorAdd": "Неуспешно добавяне на потребител към роля", "accessRoleErrorAddDescription": "Възникна грешка при добавяне на потребителя към ролята.", "userSaved": "Потребителят е запазен", "userSavedDescription": "Потребителят беше актуализиран.", "autoProvisioned": "Автоматично предоставено", "autoProvisionedDescription": "Позволете този потребител да бъде автоматично управляван от доставчик на идентификационни данни", "accessControlsDescription": "Управлявайте какво може да достъпва и прави този потребител в организацията", "accessControlsSubmit": "Запазване на контролите за достъп", "roles": "Роли", "accessUsersRoles": "Управление на потребители и роли", "accessUsersRolesDescription": "Поканете потребители и ги добавете към роли, за да управлявате достъпа до организацията", "key": "Ключ", "createdAt": "Създаден на", "proxyErrorInvalidHeader": "Невалидна стойност за заглавие на хоста. Използвайте формат на име на домейн, или оставете празно поле за да премахнете персонализирано заглавие на хост.", "proxyErrorTls": "Невалидно име на TLS сървър. Използвайте формат на име на домейн, или оставете празно за да премахнете името на TLS сървъра.", "proxyEnableSSL": "Активиране на SSL", "proxyEnableSSLDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целите.", "target": "Цел", "configureTarget": "Конфигуриране на цели", "targetErrorFetch": "Неуспешно извличане на цели", "targetErrorFetchDescription": "Възникна грешка при извличане на целите", "siteErrorFetch": "Неуспешно извличане на ресурс", "siteErrorFetchDescription": "Възникна грешка при извличане на ресурса", "targetErrorDuplicate": "Дубликат на цел", "targetErrorDuplicateDescription": "Цел с тези настройки вече съществува", "targetWireGuardErrorInvalidIp": "Невалиден таргет IP", "targetWireGuardErrorInvalidIpDescription": "Таргет IP трябва да бъде в рамките на подмрежата на сайта", "targetsUpdated": "Целите са актуализирани", "targetsUpdatedDescription": "Целите и настройките бяха успешно актуализирани", "targetsErrorUpdate": "Неуспешно актуализиране на целите", "targetsErrorUpdateDescription": "Възникна грешка при актуализиране на целите", "targetTlsUpdate": "Настройките на TLS са актуализирани", "targetTlsUpdateDescription": "Настройките за TLS бяха успешно актуализирани", "targetErrorTlsUpdate": "Неуспешно актуализиране на настройки на TLS", "targetErrorTlsUpdateDescription": "Възникна грешка при актуализиране на настройки на TLS", "proxyUpdated": "Настройките на прокси са актуализирани", "proxyUpdatedDescription": "Настройките за прокси бяха успешно актуализирани", "proxyErrorUpdate": "Неуспешно актуализиране на настройки на прокси", "proxyErrorUpdateDescription": "Възникна грешка при актуализиране на настройки на прокси", "targetAddr": "Хост", "targetPort": "Порт", "targetProtocol": "Протокол", "targetTlsSettings": "Конфигурация на защитена връзка", "targetTlsSettingsDescription": "Конфигурирайте SSL/TLS настройки за ресурса", "targetTlsSettingsAdvanced": "Разширени TLS настройки", "targetTlsSni": "Имя на TLS сървър", "targetTlsSniDescription": "Името на TLS сървъра за използване за SNI. Оставете празно, за да използвате подразбиране.", "targetTlsSubmit": "Запазване на настройките", "targets": "Конфигурация на целите", "targetsDescription": "Настройте целите да пренасочват трафика към бекенд услугите", "targetStickySessions": "Активиране на постоянни сесии", "targetStickySessionsDescription": "Запазване на връзките със същото задно целево място за цялата сесия.", "methodSelect": "Изберете метод", "targetSubmit": "Добавяне на цел", "targetNoOne": "Този ресурс няма цели. Добавете цел, за да конфигурирате къде да се изпращат заявките към бекенда.", "targetNoOneDescription": "Добавянето на повече от една цел ще активира натоварването на баланса.", "targetsSubmit": "Запазване на целите", "addTarget": "Добавете цел", "targetErrorInvalidIp": "Невалиден IP адрес", "targetErrorInvalidIpDescription": "Моля, въведете валиден IP адрес или име на хост", "targetErrorInvalidPort": "Невалиден порт", "targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт", "targetErrorNoSite": "Няма избран сайт", "targetErrorNoSiteDescription": "Моля, изберете сайт за целта", "targetCreated": "Целта е създадена", "targetCreatedDescription": "Целта беше успешно създадена", "targetErrorCreate": "Неуспешно създаване на целта", "targetErrorCreateDescription": "Възникна грешка при създаването на целта", "tlsServerName": "TLS Име на Сървъра", "tlsServerNameDescription": "TLS името на сървъра, което ще се използва за SNI", "save": "Запази", "proxyAdditional": "Допълнителни настройки на прокси", "proxyAdditionalDescription": "Конфигурирайте как ресурсът обработва настройките на прокси", "proxyCustomHeader": "Персонализиран хост заглавие", "proxyCustomHeaderDescription": "Хост заглавието, което да зададете при прокси заявките. Оставете празно, за да използвате подразбиране.", "proxyAdditionalSubmit": "Запазване на прокси настройките", "subnetMaskErrorInvalid": "Невалидна маска на мрежа. Трябва да е между 0 и 32.", "ipAddressErrorInvalidFormat": "Невалиден формат на IP адрес", "ipAddressErrorInvalidOctet": "Невалиден октет на IP адрес", "path": "Път", "matchPath": "Път на съвпадение", "ipAddressRange": "IP обхват", "rulesErrorFetch": "Неуспешно извличане на правила", "rulesErrorFetchDescription": "Възникна грешка при извличане на правилата", "rulesErrorDuplicate": "Дубликат на правило", "rulesErrorDuplicateDescription": "Правило с тези настройки вече съществува", "rulesErrorInvalidIpAddressRange": "Невалиден CIDR", "rulesErrorInvalidIpAddressRangeDescription": "Моля, въведете валидна стойност на CIDR", "rulesErrorInvalidUrl": "Невалиден URL път", "rulesErrorInvalidUrlDescription": "Моля, въведете валидна стойност за URL път", "rulesErrorInvalidIpAddress": "Невалиден IP", "rulesErrorInvalidIpAddressDescription": "Моля, въведете валиден IP адрес", "rulesErrorUpdate": "Неуспешно актуализиране на правилата", "rulesErrorUpdateDescription": "Възникна грешка при актуализиране на правилата", "rulesUpdated": "Активиране на правилата", "rulesUpdatedDescription": "Оценяването на правилата беше актуализирано", "rulesMatchIpAddressRangeDescription": "Въведете адрес във формат CIDR (напр. 103.21.244.0/22)", "rulesMatchIpAddress": "Въведете IP адрес (напр. 103.21.244.12)", "rulesMatchUrl": "Въведете URL път или модел (напр. /api/v1/todos или /api/v1/*)", "rulesErrorInvalidPriority": "Невалиден приоритет", "rulesErrorInvalidPriorityDescription": "Моля, въведете валиден приоритет", "rulesErrorDuplicatePriority": "Дублирани приоритети", "rulesErrorDuplicatePriorityDescription": "Моля, въведете уникални приоритети", "ruleUpdated": "Правилата са актуализирани", "ruleUpdatedDescription": "Правилата бяха успешно актуализирани", "ruleErrorUpdate": "Операцията не бе успешна", "ruleErrorUpdateDescription": "Възникна грешка по време на операцията за запис", "rulesPriority": "Приоритет", "rulesAction": "Действие", "rulesMatchType": "Тип на съвпадение", "value": "Стойност", "rulesAbout": "Относно правилата", "rulesAboutDescription": "Правилата ви позволяват да контролирате достъпа до ресурса въз основа на набор от критерии. Можете да създадете правила за позволяване или отказ на достъп въз основа на IP адрес или URL път.", "rulesActions": "Действия", "rulesActionAlwaysAllow": "Винаги позволи: заобикаля всички методи за автентикация", "rulesActionAlwaysDeny": "Винаги отказвай: блокиране на всички заявки; не може да се направи опит за автентикация", "rulesActionPassToAuth": "Прехвърляне към удостоверяване: Позволяване опити за методи на удостоверяване", "rulesMatchCriteria": "Критерии за съответствие", "rulesMatchCriteriaIpAddress": "Съответствие с конкретен IP адрес", "rulesMatchCriteriaIpAddressRange": "Съответства на диапазон от IP адреси в CIDR нотация", "rulesMatchCriteriaUrl": "Съответствие с път или шаблон URL", "rulesEnable": "Активирай правилата", "rulesEnableDescription": "Активиране или деактивиране на оценката на правилата за този ресурс", "rulesResource": "Конфигурация на правилата за ресурси", "rulesResourceDescription": "Конфигурирайте правила за контролиране на достъпа до ресурса", "ruleSubmit": "Добави правило", "rulesNoOne": "Няма правила. Добавете правило чрез формуляра.", "rulesOrder": "Правилата се оценяват по приоритет в нарастващ ред.", "rulesSubmit": "Запазване на правилата", "resourceErrorCreate": "Грешка при създаване на ресурс", "resourceErrorCreateDescription": "Възникна грешка при създаването на ресурса", "resourceErrorCreateMessage": "Грешка при създаване на ресурс:", "resourceErrorCreateMessageDescription": "Възникна неочаквана грешка", "sitesErrorFetch": "Грешка при получаване на сайтове", "sitesErrorFetchDescription": "Възникна грешка при получаването на сайтовете", "domainsErrorFetch": "Грешка при получаването на домейни", "domainsErrorFetchDescription": "Възникна грешка при получаването на домейните", "none": "Няма", "unknown": "Неизвестно", "resources": "Ресурси", "resourcesDescription": "Ресурсите са прокси към приложения, работещи в частната мрежа. Създайте ресурс за всяка HTTP/HTTPS или сурова TCP/UDP услуга във вашата частна мрежа. Всеки ресурс трябва да бъде свързан със сайт, за да се позволи частна, сигурна свързаност през криптиран WireGuard тунел.", "resourcesWireGuardConnect": "Сигурно свързване с криптиране на WireGuard", "resourcesMultipleAuthenticationMethods": "Конфигуриране на множество методи за автентикация", "resourcesUsersRolesAccess": "Контрол на достъпа, базиран на потребители и роли", "resourcesErrorUpdate": "Неуспешно превключване на ресурса", "resourcesErrorUpdateDescription": "Възникна грешка при актуализиране на ресурса", "access": "Достъп", "accessControl": "Контрол на достъпа", "shareLink": "{resource} Сподели връзка", "resourceSelect": "Изберете ресурс", "shareLinks": "Споделени връзки", "share": "Споделени връзки", "shareDescription2": "Създайте връзки за достъп до ресурси. Връзките предоставят временен или неограничен достъп до вашия ресурс. Можете да конфигурирате продължителността на изтичане на връзката, когато я създавате.", "shareEasyCreate": "Лесно за създаване и споделяне", "shareConfigurableExpirationDuration": "Конфигурируемо време на изтичане", "shareSecureAndRevocable": "Сигурни и отменяеми", "nameMin": "Името трябва да съдържа поне {len} знака.", "nameMax": "Името не трябва да е по-дълго от {len} знака.", "sitesConfirmCopy": "Моля, потвърдете, че сте копирали конфигурацията.", "unknownCommand": "Неизвестна команда", "newtErrorFetchReleases": "Неуспешно получаване на информация за изданието: {err}", "newtErrorFetchLatest": "Грешка при получаването на последното издание: {err}", "newtEndpoint": "Крайна точка", "newtId": "Идентификационен номер", "newtSecretKey": "Секретен ключ", "architecture": "Архитектура", "sites": "Сайтове", "siteWgAnyClients": "Използвайте клиент на WireGuard, за да се свържете. Ще трябва да използвате вътрешните ресурси чрез IP адреса на връстника.", "siteWgCompatibleAllClients": "Съвместим с всички WireGuard клиенти", "siteWgManualConfigurationRequired": "Необходима е ръчна конфигурация", "userErrorNotAdminOrOwner": "Потребителят не е администратор или собственик", "pangolinSettings": "Настройки - Панголин", "accessRoleYour": "Вашата роля:", "accessRoleSelect2": "Изберете роли", "accessUserSelect": "Изберете потребители", "otpEmailEnter": "Въведете имейл", "otpEmailEnterDescription": "Натиснете Enter, за да добавите имейл след като сте го въведели в полето за въвеждане.", "otpEmailErrorInvalid": "Невалиден имейл адрес. Wilcard (*) трябва да е цялата част от локалния адрес.", "otpEmailSmtpRequired": "Необходимо е SMTP", "otpEmailSmtpRequiredDescription": "SMTP трябва да бъде активиран на сървъра, за да използвате еднократни пароли за автентикация.", "otpEmailTitle": "Еднократни пароли", "otpEmailTitleDescription": "Изисквайте автентикация базирана на имейл за достъп до ресурси", "otpEmailWhitelist": "Бял списък на имейли", "otpEmailWhitelistList": "Имейли в белия списък", "otpEmailWhitelistListDescription": "Само потребители с тези имейл адреси ще могат да имат достъп до този ресурс. Те ще бъдат помолени да въведат еднократна парола, изпратена на техния имейл. Може да се използват wildcards (*@example.com), за да се позволи на всеки имейл адрес от домейн.", "otpEmailWhitelistSave": "Запазване на белия списък", "passwordAdd": "Добави парола", "passwordRemove": "Премахни парола", "pincodeAdd": "Добави ПИН код", "pincodeRemove": "Премахни ПИН код", "resourceAuthMethods": "Методи за автентикация", "resourceAuthMethodsDescriptions": "Позволете достъп до ресурса чрез допълнителни методи за автентикация", "resourceAuthSettingsSave": "Запазено успешно", "resourceAuthSettingsSaveDescription": "Настройките за автентикация са запазени успешно", "resourceErrorAuthFetch": "Неуспешно получаване на данни", "resourceErrorAuthFetchDescription": "Възникна грешка при получаването на данните", "resourceErrorPasswordRemove": "Грешка при премахване на паролата за ресурс", "resourceErrorPasswordRemoveDescription": "Възникна грешка при премахването на паролата за ресурс", "resourceErrorPasswordSetup": "Грешка при настройване на паролата за ресурс", "resourceErrorPasswordSetupDescription": "Възникна грешка при настройването на паролата за ресурс", "resourceErrorPincodeRemove": "Грешка при премахване на ПИН кода за ресурс", "resourceErrorPincodeRemoveDescription": "Възникна грешка при премахването на ПИН кода за ресурс", "resourceErrorPincodeSetup": "Грешка при настройване на ПИН кода за ресурс", "resourceErrorPincodeSetupDescription": "Възникна грешка при настройването на ПИН кода за ресурс", "resourceErrorUsersRolesSave": "Неуспешно задаване на роли", "resourceErrorUsersRolesSaveDescription": "Възникна грешка при задаването на ролите", "resourceErrorWhitelistSave": "Неуспешно запазване на белия списък", "resourceErrorWhitelistSaveDescription": "Възникна грешка при запазването на белия списък", "resourcePasswordSubmit": "Активирай защита с парола", "resourcePasswordProtection": "Защита с парола {status}", "resourcePasswordRemove": "Паролата на ресурса е премахната", "resourcePasswordRemoveDescription": "Премахването на паролата за ресурс беше успешно", "resourcePasswordSetup": "Паролата за ресурс е настроена", "resourcePasswordSetupDescription": "Паролата за ресурс беше успешно настроена", "resourcePasswordSetupTitle": "Задай парола", "resourcePasswordSetupTitleDescription": "Задайте парола, за да защитите този ресурс", "resourcePincode": "ПИН код", "resourcePincodeSubmit": "Активирай защита с ПИН код", "resourcePincodeProtection": "Защита с ПИН код {status}", "resourcePincodeRemove": "Премахнат ПИН код за ресурс", "resourcePincodeRemoveDescription": "Премахването на ПИН кода за ресурс беше успешно", "resourcePincodeSetup": "Настроен ПИН код за ресурс", "resourcePincodeSetupDescription": "Настройването на ПИН кода за ресурс беше успешно", "resourcePincodeSetupTitle": "Задай ПИН код", "resourcePincodeSetupTitleDescription": "Задайте ПИН код, за да защитите този ресурс", "resourceRoleDescription": "Администраторите винаги могат да имат достъп до този ресурс.", "resourceUsersRoles": "Контроли за достъп", "resourceUsersRolesDescription": "Конфигурирайте кои потребители и роли могат да посещават този ресурс", "resourceUsersRolesSubmit": "Запазване на управлението на достъп.", "resourceWhitelistSave": "Успешно запазено", "resourceWhitelistSaveDescription": "Настройките на белия списък са запазени", "ssoUse": "Използвай платформен SSO", "ssoUseDescription": "Съществуващите потребители ще трябва да влязат само веднъж за всички ресурси, които имат тази опция активирана.", "proxyErrorInvalidPort": "Невалиден номер на порт", "subdomainErrorInvalid": "Невалиден поддомейн", "domainErrorFetch": "Грешка при получаването на домейни", "domainErrorFetchDescription": "Възникна грешка при получаването на домейните", "resourceErrorUpdate": "Неуспешно актуализиране на ресурса", "resourceErrorUpdateDescription": "Възникна грешка при актуализирането на ресурса", "resourceUpdated": "Ресурсът е обновен", "resourceUpdatedDescription": "Ресурсът беше успешно обновен", "resourceErrorTransfer": "Неуспешно прехвърляне на ресурса", "resourceErrorTransferDescription": "Възникна грешка при прехвърлянето на ресурса", "resourceTransferred": "Ресурсът е прехвърлен", "resourceTransferredDescription": "Ресурсът беше успешно прехвърлен", "resourceErrorToggle": "Неуспешно превключване на ресурса", "resourceErrorToggleDescription": "Възникна грешка при актуализирането на ресурса", "resourceVisibilityTitle": "Видимост", "resourceVisibilityTitleDescription": "Напълно активирайте или деактивирайте видимостта на ресурса", "resourceGeneral": "Общи настройки", "resourceGeneralDescription": "Конфигурирайте общите настройки за този ресурс", "resourceEnable": "Активирайте ресурс", "resourceTransfer": "Прехвърлете ресурс", "resourceTransferDescription": "Прехвърлете този ресурс към различен сайт", "resourceTransferSubmit": "Прехвърлете ресурс", "siteDestination": "Дестинационен сайт", "searchSites": "Търси сайтове", "countries": "Държави", "accessRoleCreate": "Създайте роля", "accessRoleCreateDescription": "Създайте нова роля за групиране на потребители и управление на техните разрешения.", "accessRoleEdit": "Редактиране на роля", "accessRoleEditDescription": "Редактирайте информацията за ролята.", "accessRoleCreateSubmit": "Създайте роля", "accessRoleCreated": "Ролята е създадена", "accessRoleCreatedDescription": "Ролята беше успешно създадена.", "accessRoleErrorCreate": "Неуспешно създаване на роля", "accessRoleErrorCreateDescription": "Възникна грешка при създаването на ролята.", "accessRoleUpdateSubmit": "Обновете роля", "accessRoleUpdated": "Ролята е актуализирана", "accessRoleUpdatedDescription": "Ролята беше успешно актуализирана.", "accessApprovalUpdated": "Одобрението е обработено", "accessApprovalApprovedDescription": "Задайте решение на заявка за одобрение да бъде одобрено.", "accessApprovalDeniedDescription": "Задайте решение на заявка за одобрение да бъде отказано.", "accessRoleErrorUpdate": "Неуспешно актуализиране на ролята", "accessRoleErrorUpdateDescription": "Възникна грешка при актуализиране на ролята.", "accessApprovalErrorUpdate": "Неуспешно обработване на одобрение", "accessApprovalErrorUpdateDescription": "Възникна грешка при обработване на одобрението.", "accessRoleErrorNewRequired": "Нова роля е необходима", "accessRoleErrorRemove": "Неуспешно премахване на роля", "accessRoleErrorRemoveDescription": "Възникна грешка при премахването на роля.", "accessRoleName": "Име на роля", "accessRoleQuestionRemove": "Ще изтриете ролята `{name}`. Не можете да отмените това действие.", "accessRoleRemove": "Премахни роля", "accessRoleRemoveDescription": "Премахни роля от организацията", "accessRoleRemoveSubmit": "Премахни роля", "accessRoleRemoved": "Ролята е премахната", "accessRoleRemovedDescription": "Ролята беше успешно премахната.", "accessRoleRequiredRemove": "Преди да изтриете тази роля, моля изберете нова роля, към която да прехвърлите настоящите членове.", "network": "Мрежа", "manage": "Управление", "sitesNotFound": "Няма намерени сайтове.", "pangolinServerAdmin": "Администратор на сървър - Панголин", "licenseTierProfessional": "Професионален лиценз", "licenseTierEnterprise": "Предприятие лиценз", "licenseTierPersonal": "Персонален лиценз", "licensed": "Лицензиран", "yes": "Да", "no": "Не", "sitesAdditional": "Допълнителни сайтове", "licenseKeys": "Лицензионни ключове", "sitestCountDecrease": "Намаляване на броя на сайтовете", "sitestCountIncrease": "Увеличаване на броя на сайтовете", "idpManage": "Управление на доставчици на идентичност", "idpManageDescription": "Прегледайте и управлявайте доставчици на идентичност в системата", "idpGlobalModeBanner": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в администраторския панел. За да активирате IdPs за всяка организация, редактирайте конфигурацията на сървъра и задайте режима на IdP към org. Вижте документацията. Ако желаете да продължите да използвате глобалните IdPs и да премахнете това от настройките на организацията, изрично задайте режима на global в конфигурацията.", "idpGlobalModeBannerUpgradeRequired": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в администраторския панел. За да използвате доставчици на идентичност за всяка организация, трябва да надстроите до изданието Enterprise.", "idpGlobalModeBannerLicenseRequired": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в администраторския панел. За да използвате доставчици на идентичност за всяка организация, е необходим лиценз за изданието Enterprise.", "idpDeletedDescription": "Доставчик на идентичност успешно изтрит", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Сигурни ли сте, че искате да изтриете доставчика за идентичност?", "idpMessageRemove": "Това ще премахне доставчика на идентичност и всички свързани конфигурации. Потребителите, които се удостоверяват през този доставчик, вече няма да могат да влязат.", "idpMessageConfirm": "За потвърждение, моля въведете името на доставчика на идентичност по-долу.", "idpConfirmDelete": "Потвърдите изтриването на доставчик на идентичност", "idpDelete": "Изтрий доставчик на идентичност", "idp": "Доставчици на идентичност", "idpSearch": "Търси доставчици на идентичност...", "idpAdd": "Добавяне на доставчик на идентичност", "idpClientIdRequired": "Необходим е идентификационен номер на клиента.", "idpClientSecretRequired": "Необходим секретен код на клиента.", "idpErrorAuthUrlInvalid": "URL за удостоверяване трябва да бъде валиден URL.", "idpErrorTokenUrlInvalid": "URL токен трябва да бъде валиден URL.", "idpPathRequired": "Идентификаторът на пътя е необходим.", "idpScopeRequired": "Областите са задължителни.", "idpOidcDescription": "Конфигурирайте доставчик на идентичност с OpenID Connect", "idpCreatedDescription": "Доставчикът на идентичност създаден успешно", "idpCreate": "Създаване на доставчик на идентичност", "idpCreateDescription": "Конфигурирайте нов доставчик на идентичност за удостоверяване на потребителя", "idpSeeAll": "Виж всички доставчици на идентичност", "idpSettingsDescription": "Конфигурирайте основната информация за вашия доставчик на идентичност", "idpDisplayName": "Име за показване за този доставчик на идентичност", "idpAutoProvisionUsers": "Автоматично потребителско създаване", "idpAutoProvisionUsersDescription": "Когато е активирано, потребителите ще бъдат автоматично създадени в системата при първо влизане с възможност за свързване на потребителите с роли и организации.", "licenseBadge": "ЕЕ", "idpType": "Тип доставчик", "idpTypeDescription": "Изберете типа доставчик на идентичност, който искате да конфигурирате", "idpOidcConfigure": "Конфигурация на OAuth2/OIDC", "idpOidcConfigureDescription": "Конфигурирайте OAuth2/OIDC доставчика на крайни точки и кредити", "idpClientId": "ID на клиента", "idpClientIdDescription": "OAuth2 идентификационен клиент от доставчика на идентичност", "idpClientSecret": "Секретен код на клиента", "idpClientSecretDescription": "OAuth2 секретен клиент от доставчика на идентичност", "idpAuthUrl": "URL за удостоверение", "idpAuthUrlDescription": "OAuth2 крайна точка за удостоверяване URL", "idpTokenUrl": "URL на токена", "idpTokenUrlDescription": "OAuth2 крайна точка на токена URL", "idpOidcConfigureAlert": "Важно информация", "idpOidcConfigureAlertDescription": "След създаването на доставчика на идентичност, ще трябва да конфигурирате адреса за callback в настройките на доставчика на идентичност. Адресът за callback ще бъде предоставен след успешно създаване.", "idpToken": "Конфигуриране на токените", "idpTokenDescription": "Конфигурирайте как да извлечете информация за потребителя от ID токена", "idpJmespathAbout": "Относно JMESPath", "idpJmespathAboutDescription": "Пътищата по-долу използват синтаксиса JMESPath за извличане на стойности от ID токена.", "idpJmespathAboutDescriptionLink": "Научете повече за JMESPath", "idpJmespathLabel": "Идентификатор на пътя", "idpJmespathLabelDescription": "Пътят към идентификатора на потребителя в ID токена", "idpJmespathEmailPathOptional": "Път за имейл (по избор)", "idpJmespathEmailPathOptionalDescription": "Пътят до имейла на потребителя в ID токена", "idpJmespathNamePathOptional": "Път (по избор) на име", "idpJmespathNamePathOptionalDescription": "Пътят до името на потребителя в ID токена", "idpOidcConfigureScopes": "Области", "idpOidcConfigureScopesDescription": "Списък от разделените се с интервали OAuth2 области, които да се заявят", "idpSubmit": "Създайте доставчик на идентичност", "orgPolicies": "Организационни политики", "idpSettings": "{idpName} Настройки", "idpCreateSettingsDescription": "Конфигурирайте настройките за доставчика на идентичност", "roleMapping": "Ролева карта", "orgMapping": "Организационна карта", "orgPoliciesSearch": "Търсене на организационни политики...", "orgPoliciesAdd": "Добавяне на организационна политика", "orgRequired": "Организацията е задължителна", "error": "Грешка", "success": "Успех", "orgPolicyAddedDescription": "Политиката беше добавена успешно", "orgPolicyUpdatedDescription": "Политиката беше актуализирана успешно", "orgPolicyDeletedDescription": "Политиката беше изтрита успешно", "defaultMappingsUpdatedDescription": "Файловете по подразбиране бяха актуализирани успешно", "orgPoliciesAbout": "За Организационни политики", "orgPoliciesAboutDescription": "Организационните политики се използват за контрол на достъпа до организации въз основа на идентификационния токен на потребителя. Можете да зададете JMESPath изрази за извличане на роля и информация за организацията от идентификационния токен.", "orgPoliciesAboutDescriptionLink": "Вижте документацията за повече информация.", "defaultMappingsOptional": "Файлове по подразбиране (По желание)", "defaultMappingsOptionalDescription": "Файловете по подразбиране се използват, когато няма дефинирана организационна политика за организацията. Можете да зададете роля и файлове за организацията по подразбиране, които да се използват в този случай.", "defaultMappingsRole": "Карта на роля по подразбиране", "defaultMappingsRoleDescription": "Резултатът от този израз трябва да върне името на ролята, както е дефинирано в организацията, като стринг.", "defaultMappingsOrg": "Карта на организация по подразбиране", "defaultMappingsOrgDescription": "Този израз трябва да върне ID на организацията или 'true', за да бъде разрешен достъпът на потребителя до организацията.", "defaultMappingsSubmit": "Запазване на файловете по подразбиране", "orgPoliciesEdit": "Редактиране на Организационна Политика", "org": "Организация", "orgSelect": "Изберете организация", "orgSearch": "Търсене на организация", "orgNotFound": "Не е намерена организация.", "roleMappingPathOptional": "Път на ролята (По желание)", "orgMappingPathOptional": "Път на организацията (По желание)", "orgPolicyUpdate": "Актуализиране на Политика", "orgPolicyAdd": "Добавяне на Политика", "orgPolicyConfig": "Конфигуриране на достъп за организация", "idpUpdatedDescription": "Идентификационният доставчик беше актуализиран успешно", "redirectUrl": "URL за пренасочване", "orgIdpRedirectUrls": "URL адреси за пренасочване", "redirectUrlAbout": "За URL за пренасочване", "redirectUrlAboutDescription": "Това е URL адресът, към който потребителите ще бъдат пренасочени след удостоверяване. Трябва да конфигурирате този URL адрес в настройките на доставчика на идентичност.", "pangolinAuth": "Authent - Pangolin", "verificationCodeLengthRequirements": "Вашият код за удостоверяване трябва да бъде 8 символа.", "errorOccurred": "Възникна грешка", "emailErrorVerify": "Неуспешно удостоверяване на имейл:", "emailVerified": "Имейлът беше успешно удостоверен! Пренасочване...", "verificationCodeErrorResend": "Неуспешно изпращане на код за удостоверяване отново:", "verificationCodeResend": "Кодът за удостоверяване беше изпратен отново", "verificationCodeResendDescription": "Изпратихме код за удостоверяване на вашия имейл адрес. Моля, проверете входящата си поща.", "emailVerify": "Потвърждаване на имейл", "emailVerifyDescription": "Въведете кода за удостоверяване, изпратен на вашия имейл адрес.", "verificationCode": "Код за удостоверяване", "verificationCodeEmailSent": "Изпратихме код за удостоверяване на вашия имейл адрес.", "submit": "Изпращане", "emailVerifyResendProgress": "Пренасочване...", "emailVerifyResend": "Не получихте код? Кликнете тук, за да изпратите отново", "passwordNotMatch": "Паролите не съвпадат", "signupError": "Възникна грешка при регистрация", "pangolinLogoAlt": "Лого на Pangolin", "inviteAlready": "Изглежда, че сте били поканени!", "inviteAlreadyDescription": "За да приемете поканата, трябва да влезете или да създадете акаунт.", "signupQuestion": "Вече имате акаунт?", "login": "Вход", "resourceNotFound": "Ресурсът не е намерен", "resourceNotFoundDescription": "Ресурсът, който се опитвате да достъпите, не съществува.", "pincodeRequirementsLength": "ПИН трябва да бъде точно 6 цифри", "pincodeRequirementsChars": "ПИН трябва да съдържа само цифри", "passwordRequirementsLength": "Паролата трябва да бъде поне 1 символа дълга", "passwordRequirementsTitle": "Изисквания към паролата:", "passwordRequirementLength": "Поне 8 символа дължина", "passwordRequirementUppercase": "Поне една главна буква", "passwordRequirementLowercase": "Поне една малка буква", "passwordRequirementNumber": "Поне една цифра", "passwordRequirementSpecial": "Поне един специален символ", "passwordRequirementsMet": "✓ Паролата отговаря на всички изисквания", "passwordStrength": "Сила на паролата", "passwordStrengthWeak": "Слаба", "passwordStrengthMedium": "Средна", "passwordStrengthStrong": "Силна", "passwordRequirements": "Изисквания:", "passwordRequirementLengthText": "8+ символа", "passwordRequirementUppercaseText": "Главна буква (A-Z)", "passwordRequirementLowercaseText": "Малка буква (a-z)", "passwordRequirementNumberText": "Цифра (0-9)", "passwordRequirementSpecialText": "Специален символ (!@#$%...)", "passwordsDoNotMatch": "Паролите не съвпадат", "otpEmailRequirementsLength": "OTP трябва да бъде поне 1 символа дълга", "otpEmailSent": "OTP изпратен", "otpEmailSentDescription": "OTP беше изпратен на вашия имейл", "otpEmailErrorAuthenticate": "Неуспешно удостоверяване чрез имейл", "pincodeErrorAuthenticate": "Неуспешно удостоверяване чрез ПИН", "passwordErrorAuthenticate": "Неуспешно удостоверяване чрез парола", "poweredBy": "Поддържано от", "authenticationRequired": "Необходимо е удостоверяване", "authenticationMethodChoose": "Изберете предпочитаният метод за достъп до {name}", "authenticationRequest": "Трябва да удостоверите за достъп до {name}", "user": "Потребител", "pincodeInput": "6-цифрен ПИН код", "pincodeSubmit": "Влез с ПИН", "passwordSubmit": "Влез с Парола", "otpEmailDescription": "Код за еднократна употреба ще бъде изпратен на този имейл.", "otpEmailSend": "Изпращане на код за еднократна употреба", "otpEmail": "Парола за еднократна употреба (OTP)", "otpEmailSubmit": "Изпрати OTP", "backToEmail": "Назад към Имейл", "noSupportKey": "Сървърът работи без поддържащ ключ. Разгледайте възможностите за подкрепа на проекта!", "accessDenied": "Достъпът е отказан", "accessDeniedDescription": "Не ви е разрешен достъпът до този ресурс. Ако това е грешка, моля свържете се с администратора.", "accessTokenError": "Грешка при проверка на достъпния токен", "accessGranted": "Достъпът е разрешен", "accessUrlInvalid": "Невалиден URL за достъп", "accessGrantedDescription": "Достъпът до този ресурс ви е разрешен. Пренасочване...", "accessUrlInvalidDescription": "Този споделен URL за достъп е невалиден. Моля, свържете се със собственика на ресурса за нов URL.", "tokenInvalid": "Невалиден токен", "pincodeInvalid": "Невалиден код", "passwordErrorRequestReset": "Неуспешно заявление за нулиране:", "passwordErrorReset": "Неуспешно нулиране на паролата:", "passwordResetSuccess": "Паролата е успешно нулирана! Връщане към вход...", "passwordReset": "Нулиране на парола", "passwordResetDescription": "Следвайте стъпките, за да нулирате паролата си", "passwordResetSent": "Ще изпратим код за нулиране на паролата на този имейл адрес.", "passwordResetCode": "Код за нулиране", "passwordResetCodeDescription": "Проверете имейла си за код за нулиране.", "generatePasswordResetCode": "Генериране на код за нулиране на парола", "passwordResetCodeGenerated": "Кодът за нулиране на парола е генериран", "passwordResetCodeGeneratedDescription": "Споделете този код с потребителя. Той може да го използва, за да нулира паролата си.", "passwordResetUrl": "URL за нулиране", "passwordNew": "Нова парола", "passwordNewConfirm": "Потвърдете новата парола", "changePassword": "Промяна на парола", "changePasswordDescription": "Актуализиране на паролата на акаунта", "oldPassword": "Текуща парола", "newPassword": "Нова парола", "confirmNewPassword": "Потвърдете новата парола", "changePasswordError": "Неуспешна промяна на парола", "changePasswordErrorDescription": "Възникна грешка при промяна на вашата парола", "changePasswordSuccess": "Паролата беше успешно променена", "changePasswordSuccessDescription": "Вашата парола беше успешно актуализирана", "passwordExpiryRequired": "Изисква се изтичане на срока на годност на паролата", "passwordExpiryDescription": "Тази организация изисква да сменяте паролата си на всеки {maxDays} дни.", "changePasswordNow": "Сменете паролата сега", "pincodeAuth": "Код на удостоверителя", "pincodeSubmit2": "Изпратете кода", "passwordResetSubmit": "Заявка за нулиране", "passwordResetAlreadyHaveCode": "Въведете код.", "passwordResetSmtpRequired": "Моля, свържете се с вашия администратор", "passwordResetSmtpRequiredDescription": "Кодът за нулиране на парола е задължителен за нулиране на паролата ви. Моля, свържете се с вашия администратор за помощ.", "passwordBack": "Назад към Парола", "loginBack": "Върнете се на главната страница за вход", "signup": "Регистрация", "loginStart": "Влезте, за да започнете", "idpOidcTokenValidating": "Валидиране на OIDC токен", "idpOidcTokenResponse": "Валидирайте отговора на OIDC токен", "idpErrorOidcTokenValidating": "Грешка при валидиране на OIDC токен", "idpConnectingTo": "Свързване с {name}", "idpConnectingToDescription": "Валидиране на идентичността ви", "idpConnectingToProcess": "Свързва се...", "idpConnectingToFinished": "Свързано", "idpErrorConnectingTo": "Имаше проблем със свързването към {name}. Моля, свържете се с вашия администратор.", "idpErrorNotFound": "Не е намерен идентификационен доставчик", "inviteInvalid": "Невалидна покана", "inviteInvalidDescription": "Линкът към поканата е невалиден.", "inviteErrorWrongUser": "Поканата не е за този потребител", "inviteErrorUserNotExists": "Потребителят не съществува. Моля, създайте акаунт първо.", "inviteErrorLoginRequired": "Трябва да сте влезли, за да приемете покана", "inviteErrorExpired": "Може би поканата е изтекла", "inviteErrorRevoked": "Поканата може да е била отменена", "inviteErrorTypo": "Може би има грешка при въвеждане в линка за поканата", "pangolinSetup": "Настройка - Pangolin", "orgNameRequired": "Името на организацията е задължително", "orgIdRequired": "ID на организацията е задължително", "orgIdMaxLength": "ID на организация трябва да бъде най-много 32 символа", "orgErrorCreate": "Възникна грешка при създаване на организация", "pageNotFound": "Страницата не е намерена", "pageNotFoundDescription": "О, не! Страницата, която търсите, не съществува.", "overview": "Общ преглед", "home": "Начало", "settings": "Настройки", "usersAll": "Всички потребители", "license": "Лиценз", "pangolinDashboard": "Табло за управление - Pangolin", "noResults": "Няма намерени резултати.", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "Въведени тагове", "tagsEnteredDescription": "Това са таговете, които сте въвели.", "tagsWarnCannotBeLessThanZero": "maxTags и minTags не могат да бъдат по-малки от 0", "tagsWarnNotAllowedAutocompleteOptions": "Таг не е разрешен, съобразно опциите за автозавършване", "tagsWarnInvalid": "Невалиден таг според validateTag", "tagWarnTooShort": "Таг {tagText} е твърде кратък", "tagWarnTooLong": "Таг {tagText} е твърде дълъг", "tagsWarnReachedMaxNumber": "Достигнат е максималния брой разрешени тагове", "tagWarnDuplicate": "Дублиран таг {tagText} не е добавен", "supportKeyInvalid": "Невалиден ключ", "supportKeyInvalidDescription": "Вашият поддържащ ключ е невалиден.", "supportKeyValid": "Валиден ключ", "supportKeyValidDescription": "Вашият поддържащ ключ е валидиран. Благодарим за подкрепата!", "supportKeyErrorValidationDescription": "Неуспешна валидиция на поддържащ ключ.", "supportKey": "Подкрепете развитието и си осинови Панголин!", "supportKeyDescription": "Купете поддържащ ключ, за да ни помогнете да продължим развитието на Pangolin за общността. Вашата помощ ни позволява да отделим повече време за поддръжка и добавяне на нови функции към приложението за всички. Ние никога няма да използваме това за заплащане на функции. Това е отделно от каквото и да е издание за комерсиални цели.", "supportKeyPet": "Вие също ще имате възможност да осиновите и срещнете вашия собствен домашен панголин!", "supportKeyPurchase": "Плащания се обработват през GitHub. След това можете да получите вашия ключ от", "supportKeyPurchaseLink": "нашия уебсайт", "supportKeyPurchase2": "и да го използвате тук.", "supportKeyLearnMore": "Научете повече.", "supportKeyOptions": "Изберете опцията, която най-добре съответства на вас.", "supportKetOptionFull": "Пълна поддръжка", "forWholeServer": "За целия сървър", "lifetimePurchase": "Пожизнена покупка", "supporterStatus": "Статус на поддръжника", "buy": "Купи", "supportKeyOptionLimited": "Ограничена поддръжка", "forFiveUsers": "За 5 или по-малко потребители", "supportKeyRedeem": "Изкупи поддържащ ключ", "supportKeyHideSevenDays": "Скрий за 7 дни", "supportKeyEnter": "Въведете поддържащ ключ", "supportKeyEnterDescription": "Запознайте се с вашия собствен домашен панголин!", "githubUsername": "Потребителско име в GitHub", "supportKeyInput": "Поддържащ ключ", "supportKeyBuy": "Купи поддържащ ключ", "logoutError": "Грешка при излизане", "signingAs": "Влезли сте като", "serverAdmin": "Администратор на сървъра", "managedSelfhosted": "Управлявано Самостоятелно-хоствано", "otpEnable": "Включване на двуфакторен", "otpDisable": "Изключване на двуфакторен", "logout": "Изход", "licenseTierProfessionalRequired": "Изисква се професионално издание", "licenseTierProfessionalRequiredDescription": "Тази функция е достъпна само в професионалното издание.", "actionGetOrg": "Вземете организация", "updateOrgUser": "Актуализиране на Организационна Потребител", "createOrgUser": "Създаване на Организационна Потребител", "actionUpdateOrg": "Актуализиране на организацията", "actionRemoveInvitation": "Премахване на поканата.", "actionUpdateUser": "Актуализиране на потребител", "actionGetUser": "Получаване на потребител", "actionGetOrgUser": "Вземете потребител на организация", "actionListOrgDomains": "Изброяване на домейни на организация", "actionGetDomain": "Вземи домейн", "actionCreateOrgDomain": "Създай домейн", "actionUpdateOrgDomain": "Актуализирай домейн", "actionDeleteOrgDomain": "Изтрий домейн", "actionGetDNSRecords": "Вземи DNS записи", "actionRestartOrgDomain": "Рестартирай домейн", "actionCreateSite": "Създаване на сайт", "actionDeleteSite": "Изтриване на сайта", "actionGetSite": "Вземете сайт", "actionListSites": "Изброяване на сайтове", "actionApplyBlueprint": "Приложи Чернова", "actionListBlueprints": "Списък с планове.", "actionGetBlueprint": "Вземи план.", "setupToken": "Конфигурация на токен", "setupTokenDescription": "Въведете конфигурационния токен от сървърната конзола.", "setupTokenRequired": "Необходим е конфигурационен токен", "actionUpdateSite": "Актуализиране на сайт", "actionListSiteRoles": "Изброяване на позволените роли за сайта", "actionCreateResource": "Създаване на ресурс", "actionDeleteResource": "Изтриване на ресурс", "actionGetResource": "Вземете ресурс", "actionListResource": "Изброяване на ресурси", "actionUpdateResource": "Актуализиране на ресурс", "actionListResourceUsers": "Изброяване на потребители на ресурси", "actionSetResourceUsers": "Задайте потребители на ресурси", "actionSetAllowedResourceRoles": "Задайте позволени роли за ресурса", "actionListAllowedResourceRoles": "Изброяване на позволени роли за ресурси", "actionSetResourcePassword": "Задайте парола на ресурса", "actionSetResourcePincode": "Задайте ПИН код на ресурса", "actionSetResourceEmailWhitelist": "Задайте списък на одобрените имейл адреси за ресурса", "actionGetResourceEmailWhitelist": "Вземете списък на одобрените имейл адреси за ресурса", "actionCreateTarget": "Създайте цел", "actionDeleteTarget": "Изтрийте цел", "actionGetTarget": "Вземете цел", "actionListTargets": "Изброяване на цели", "actionUpdateTarget": "Актуализирайте цел", "actionCreateRole": "Създайте роля", "actionDeleteRole": "Изтрийте роля", "actionGetRole": "Вземете роля", "actionListRole": "Изброяване на роли", "actionUpdateRole": "Актуализирайте роля", "actionListAllowedRoleResources": "Изброяване на разрешени ресурси за роля", "actionInviteUser": "Покани потребител", "actionRemoveUser": "Изтрийте потребител", "actionListUsers": "Изброяване на потребители", "actionAddUserRole": "Добавяне на роля на потребител", "actionGenerateAccessToken": "Генериране на токен за достъп", "actionDeleteAccessToken": "Изтриване на токен за достъп", "actionListAccessTokens": "Изброяване на токени за достъп", "actionCreateResourceRule": "Създаване на правило за ресурс", "actionDeleteResourceRule": "Изтрийте правило за ресурс", "actionListResourceRules": "Изброяване на правила за ресурс", "actionUpdateResourceRule": "Актуализиране на правило за ресурс", "actionListOrgs": "Изброяване на организации", "actionCheckOrgId": "Проверка на ID на организацията", "actionCreateOrg": "Създаване на организация", "actionDeleteOrg": "Изтриване на организация", "actionListApiKeys": "Изброяване на API ключове", "actionListApiKeyActions": "Изброяване на действия за API ключ", "actionSetApiKeyActions": "Задайте разрешени действия за API ключ", "actionCreateApiKey": "Създаване на API ключ", "actionDeleteApiKey": "Изтриване на API ключ", "actionCreateIdp": "Създаване на IdP", "actionUpdateIdp": "Актуализиране на IdP", "actionDeleteIdp": "Изтриване на IdP", "actionListIdps": "Изброяване на IdP", "actionGetIdp": "Вземете IdP", "actionCreateIdpOrg": "Създаване на политика за IdP организация", "actionDeleteIdpOrg": "Изтриване на политика за IdP организация", "actionListIdpOrgs": "Изброяване на IdP организации", "actionUpdateIdpOrg": "Актуализиране на IdP организация", "actionCreateClient": "Създаване на клиент", "actionDeleteClient": "Изтриване на клиент", "actionArchiveClient": "Архивиране на клиента", "actionUnarchiveClient": "Разархивиране на клиента", "actionBlockClient": "Блокиране на клиента", "actionUnblockClient": "Деблокиране на клиента", "actionUpdateClient": "Актуализиране на клиент", "actionListClients": "Списък с клиенти", "actionGetClient": "Получаване на клиент", "actionCreateSiteResource": "Създаване на сайт ресурс", "actionDeleteSiteResource": "Изтриване на сайт ресурс", "actionGetSiteResource": "Получаване на сайт ресурс", "actionListSiteResources": "Списък на ресурсите на сайта", "actionUpdateSiteResource": "Актуализиране на сайт ресурс", "actionListInvitations": "Списък с покани", "actionExportLogs": "Експортиране на дневници", "actionViewLogs": "Преглед на дневници", "noneSelected": "Нищо не е избрано", "orgNotFound2": "Няма намерени организации.", "searchPlaceholder": "Търсене...", "emptySearchOptions": "Няма намерени опции", "create": "Създаване", "orgs": "Организации", "loginError": "Възникна неочаквана грешка. Моля, опитайте отново.", "loginRequiredForDevice": "Необходим е вход за вашето устройство.", "passwordForgot": "Забравена парола?", "otpAuth": "Двуфакторно удостоверяване", "otpAuthDescription": "Въведете кода от приложението за удостоверяване или един от вашите резервни кодове за еднократна употреба.", "otpAuthSubmit": "Изпрати код", "idpContinue": "Или продължете със", "otpAuthBack": "Назад към парола", "navbar": "Навигационно меню", "navbarDescription": "Главно навигационно меню за приложението", "navbarDocsLink": "Документация", "otpErrorEnable": "Не може да се активира 2FA", "otpErrorEnableDescription": "Възникна грешка при активиране на 2FA", "otpSetupCheckCode": "Моля, въведете 6-цифрен код", "otpSetupCheckCodeRetry": "Невалиден код. Моля, опитайте отново.", "otpSetup": "Активиране на двуфакторно удостоверяване", "otpSetupDescription": "Защитете профила си с допълнителен слой защита", "otpSetupScanQr": "Сканирайте този QR код с вашето приложение за автентикация или въведете ръчно тайния ключ:", "otpSetupSecretCode": "Код за автентикация", "otpSetupSuccess": "Двуфакторното удостоверяване е активирано", "otpSetupSuccessStoreBackupCodes": "Профилът ви сега е по-сигурен. Не забравяйте да запазите резервните си кодове.", "otpErrorDisable": "Не може да се деактивира 2FA", "otpErrorDisableDescription": "Възникна грешка при деактивиране на 2FA", "otpRemove": "Деактивиране на двуфакторно удостоверяване", "otpRemoveDescription": "Деактивирайте двуфакторното удостоверяване за вашия профил", "otpRemoveSuccess": "Двуфакторното удостоверяване е деактивирано", "otpRemoveSuccessMessage": "Двуфакторното удостоверяване за вашия профил е деактивирано. Можете да го активирате отново по всяко време.", "otpRemoveSubmit": "Деактивиране на 2FA", "paginator": "Страница {current} от {last}", "paginatorToFirst": "Отидете на първата страница", "paginatorToPrevious": "Отидете на предишната страница", "paginatorToNext": "Отидете на следващата страница", "paginatorToLast": "Отидете на последната страница", "copyText": "Копиране на текст", "copyTextFailed": "Неуспешно копиране на текст: ", "copyTextClipboard": "Копиране в клипборда", "inviteErrorInvalidConfirmation": "Невалидно потвърждение", "passwordRequired": "Паролата е задължителна", "allowAll": "Разрешаване на всички", "permissionsAllowAll": "Разрешаване на всички разрешения", "githubUsernameRequired": "GitHub потребителското име е задължително", "supportKeyRequired": "Ключът на поддръжката е задължителен", "passwordRequirementsChars": "Паролата трябва да е поне 8 символа", "language": "Език", "verificationCodeRequired": "Кодът е задължителен", "userErrorNoUpdate": "Няма потребител за актуализиране", "siteErrorNoUpdate": "Няма сайт за актуализиране", "resourceErrorNoUpdate": "Няма ресурс за актуализиране", "authErrorNoUpdate": "Няма информация за удостоверяване за актуализиране", "orgErrorNoUpdate": "Няма организация за актуализиране", "orgErrorNoProvided": "Няма предоставена организация", "apiKeysErrorNoUpdate": "Няма API ключ за актуализиране", "sidebarOverview": "Общ преглед", "sidebarHome": "Начало", "sidebarSites": "Сайтове", "sidebarApprovals": "Заявки за одобрение", "sidebarResources": "Ресурси", "sidebarProxyResources": "Публично", "sidebarClientResources": "Частно", "sidebarAccessControl": "Контрол на достъпа", "sidebarLogsAndAnalytics": "Дневници и анализи", "sidebarTeam": "Екип", "sidebarUsers": "Потребители", "sidebarAdmin": "Администратор", "sidebarInvitations": "Покани", "sidebarRoles": "Роли", "sidebarShareableLinks": "Връзки", "sidebarApiKeys": "API ключове", "sidebarSettings": "Настройки", "sidebarAllUsers": "Всички потребители", "sidebarIdentityProviders": "Идентификационни доставчици", "sidebarLicense": "Лиценз", "sidebarClients": "Клиенти", "sidebarUserDevices": "Устройства на потребителя", "sidebarMachineClients": "Машини", "sidebarDomains": "Домейни", "sidebarGeneral": "Управление.", "sidebarLogAndAnalytics": "Лог & Анализи", "sidebarBluePrints": "Чертежи", "sidebarOrganization": "Организация", "sidebarManagement": "Управление", "sidebarBillingAndLicenses": "Фактуриране & Лицензи", "sidebarLogsAnalytics": "Анализи", "blueprints": "Чертежи", "blueprintsDescription": "Прилагайте декларативни конфигурации и преглеждайте предишни изпълнения", "blueprintAdd": "Добави Чертеж", "blueprintGoBack": "Виж всички Чертежи", "blueprintCreate": "Създай Чертеж", "blueprintCreateDescription2": "Следвайте стъпките по-долу, за да създадете и приложите нов чертеж", "blueprintDetails": "Детайли на чертежа", "blueprintDetailsDescription": "Вижте резултата от приложените чертежи и всички възникнали грешки", "blueprintInfo": "Информация за Чертежа", "message": "Съобщение", "blueprintContentsDescription": "Определете съдържанието на YAML, описващо инфраструктурата", "blueprintErrorCreateDescription": "Възникна грешка при прилагането на чертежа", "blueprintErrorCreate": "Грешка при създаването на чертеж", "searchBlueprintProgress": "Търси чертежи...", "appliedAt": "Приложено във", "source": "Източник", "contents": "Съдържание", "parsedContents": "Парсирано съдържание (само за четене)", "enableDockerSocket": "Активиране на Docker Чернова", "enableDockerSocketDescription": "Активиране на Docker Socket маркировка за изтегляне на етикети на чернова. Пътят на гнездото трябва да бъде предоставен на Newt.", "viewDockerContainers": "Преглед на Docker контейнери", "containersIn": "Контейнери в {siteName}", "selectContainerDescription": "Изберете контейнер, който да ползвате като име на хост за целта. Натиснете порт, за да ползвате порт", "containerName": "Име", "containerImage": "Образ", "containerState": "Състояние", "containerNetworks": "Мрежи", "containerHostnameIp": "Име на хост/IP", "containerLabels": "Етикети", "containerLabelsCount": "{count, plural, one {# етикет} other {# етикета}}", "containerLabelsTitle": "Етикети на контейнера", "containerLabelEmpty": "<празно>", "containerPorts": "Портове", "containerPortsMore": "+{count} още", "containerActions": "Действия", "select": "Избор", "noContainersMatchingFilters": "Не са намерени контейнери, съответстващи на текущите филтри.", "showContainersWithoutPorts": "Показване на контейнери без портове", "showStoppedContainers": "Показване на спрени контейнери", "noContainersFound": "Не са намерени контейнери. Уверете се, че Docker контейнерите са активирани.", "searchContainersPlaceholder": "Търсене сред {count} контейнери...", "searchResultsCount": "{count, plural, one {# резултат} other {# резултати}}", "filters": "Филтри", "filterOptions": "Опции за филтриране", "filterPorts": "Портове", "filterStopped": "Спрени", "clearAllFilters": "Изчистване на всички филтри", "columns": "Колони", "toggleColumns": "Превключване на колони", "refreshContainersList": "Обновяване на списъка с контейнери", "searching": "Търсене...", "noContainersFoundMatching": "Не са намерени контейнери, съответстващи на \"{filter}\".", "light": "светъл", "dark": "тъмен", "system": "система", "theme": "Тема", "subnetRequired": "Необходим е подмрежа", "initialSetupTitle": "Начална конфигурация на сървъра", "initialSetupDescription": "Създайте администраторски акаунт на сървъра. Може да съществува само един администраторски акаунт. Винаги можете да промените тези данни по-късно.", "createAdminAccount": "Създаване на админ акаунт", "setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.", "certificateStatus": "Статус на сертификата", "loading": "Зареждане", "loadingAnalytics": "Зареждане на анализи", "restart": "Рестарт", "domains": "Домейни", "domainsDescription": "Създайте и управлявайте наличните домейни в организацията", "domainsSearch": "Търсене на домейни...", "domainAdd": "Добавяне на домейн", "domainAddDescription": "Регистрирайте нов домейн в организацията", "domainCreate": "Създаване на домейн", "domainCreatedDescription": "Домейнът е създаден успешно", "domainDeletedDescription": "Домейнът е изтрит успешно", "domainQuestionRemove": "Сигурни ли сте, че искате да премахнете домейна?", "domainMessageRemove": "След като бъде премахнат, домейнът вече няма да бъде свързан с организацията.", "domainConfirmDelete": "Потвърдете изтриването на домейн", "domainDelete": "Изтриване на домейн", "domain": "Домейн", "selectDomainTypeNsName": "Делегация на домейни (NS)", "selectDomainTypeNsDescription": "Този домейн и всичките му поддомейни. Ползвайте го, когато искате да контролирате цялата зона на домейна.", "selectDomainTypeCnameName": "Единичен домейн (CNAME)", "selectDomainTypeCnameDescription": "Само този специфичен домейн. Ползвайте го за индивидуални поддомейни или специфични домейн записи.", "selectDomainTypeWildcardName": "Общ домейн", "selectDomainTypeWildcardDescription": "Този домейн и неговите поддомейни.", "domainDelegation": "Единичен домейн", "selectType": "Изберете тип", "actions": "Действия", "refresh": "Обновяване", "refreshError": "Неуспешно обновяване на данни", "verified": "Потвърдено", "pending": "Чакащо", "pendingApproval": "Очаква одобрение", "sidebarBilling": "Фактуриране", "billing": "Фактуриране", "orgBillingDescription": "Управлявайте информацията за плащане и абонаментите", "github": "GitHub", "pangolinHosted": "Hosted Pangolin", "fossorial": "Fossorial", "completeAccountSetup": "Завършете настройката на профила", "completeAccountSetupDescription": "Задайте паролата си, за да започнете", "accountSetupSent": "Ще изпратим код за настройка на профила на този адрес имейл.", "accountSetupCode": "Код за настройка", "accountSetupCodeDescription": "Проверете имейла си за код за настройка.", "passwordCreate": "Създайте парола", "passwordCreateConfirm": "Потвърждение на паролата", "accountSetupSubmit": "Изпращане на код за настройка", "completeSetup": "Завършване на настройката", "accountSetupSuccess": "Настройката на профила завърши успешно! Добре дошли в Pangolin!", "documentation": "Документация", "saveAllSettings": "Запазване на всички настройки", "saveResourceTargets": "Запазване на целеви ресурси.", "saveResourceHttp": "Запазване на прокси настройките.", "saveProxyProtocol": "Запазване на настройките на прокси протокола.", "settingsUpdated": "Настройките са обновени", "settingsUpdatedDescription": "Настройките са успешно актуализирани.", "settingsErrorUpdate": "Неуспешно обновяване на настройките", "settingsErrorUpdateDescription": "Възникна грешка при обновяване на настройките", "sidebarCollapse": "Свиване", "sidebarExpand": "Разширяване", "productUpdateMoreInfo": "{noOfUpdates} още актуализации", "productUpdateInfo": "{noOfUpdates} актуализации", "productUpdateWhatsNew": "Какво ново", "productUpdateTitle": "Актуализации на продукта", "productUpdateEmpty": "Няма актуализации", "dismissAll": "Отхвърляне на всички", "pangolinUpdateAvailable": "Актуализация е налична", "pangolinUpdateAvailableInfo": "Версия {version} е готова за инсталиране", "pangolinUpdateAvailableReleaseNotes": "Преглед на бележките за изданието", "newtUpdateAvailable": "Ново обновление", "newtUpdateAvailableInfo": "Нова версия на Newt е налична. Моля, обновете до последната версия за най-добро изживяване.", "domainPickerEnterDomain": "Домейн", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Въведете пълния домейн на ресурса, за да видите наличните опции.", "domainPickerDescriptionSaas": "Въведете пълен домейн, поддомейн или само име, за да видите наличните опции", "domainPickerTabAll": "Всички", "domainPickerTabOrganization": "Организация", "domainPickerTabProvided": "Предоставен", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Проверка на наличността...", "domainPickerNoMatchingDomains": "Не са намерени съвпадащи домейни. Опитайте различен домейн или проверете настройките на домейна на организацията.", "domainPickerOrganizationDomains": "Домейни на организацията", "domainPickerProvidedDomains": "Предоставени домейни", "domainPickerSubdomain": "Поддомейн: {subdomain}", "domainPickerNamespace": "Име на пространство: {namespace}", "domainPickerShowMore": "Покажи повече", "regionSelectorTitle": "Избор на регион", "regionSelectorInfo": "Изборът на регион ни помага да предоставим по-добра производителност за вашето местоположение. Не е необходимо да сте в същия регион като сървъра.", "regionSelectorPlaceholder": "Изберете регион", "regionSelectorComingSoon": "Очаква се скоро", "billingLoadingSubscription": "Зареждане на абонамент...", "billingFreeTier": "Безплатен план", "billingWarningOverLimit": "Предупреждение: Превишили сте една или повече лимити за използване. Вашите сайтове няма да се свържат, докато не промените абонамента си или не коригирате използването.", "billingUsageLimitsOverview": "Преглед на лимитите за използване", "billingMonitorUsage": "Следете своята употреба спрямо конфигурираните лимити. Ако имате нужда от увеличаване на лимитите, моля свържете се с нас support@pangolin.net.", "billingDataUsage": "Използване на данни", "billingSites": "Сайтове", "billingUsers": "Потребители", "billingDomains": "Домейни", "billingOrganizations": "Организации", "billingRemoteExitNodes": "Дистанционни възли", "billingNoLimitConfigured": "Няма конфигуриран лимит", "billingEstimatedPeriod": "Очакван период на фактуриране", "billingIncludedUsage": "Включено използване", "billingIncludedUsageDescription": "Използване, включено във вашия текущ абонаментен план", "billingFreeTierIncludedUsage": "Разрешени използвания в безплатния план", "billingIncluded": "включено", "billingEstimatedTotal": "Очаквана сума:", "billingNotes": "Бележки", "billingEstimateNote": "Това е приблизителна оценка, основана на текущото ви използване.", "billingActualChargesMayVary": "Реалните разходи могат да варират.", "billingBilledAtEnd": "Фактурирате се в края на фактурния период.", "billingModifySubscription": "Промяна на абонамента", "billingStartSubscription": "Започване на абонамент", "billingRecurringCharge": "Повтаряща се такса", "billingManageSubscriptionSettings": "Управление на настройките и предпочитанията за абонамент", "billingNoActiveSubscription": "Нямате активен абонамент. Започнете абонамента си, за да увеличите лимитите за използване.", "billingFailedToLoadSubscription": "Грешка при зареждане на абонамент", "billingFailedToLoadUsage": "Грешка при зареждане на използването", "billingFailedToGetCheckoutUrl": "Неуспех при получаване на URL за плащане", "billingPleaseTryAgainLater": "Моля, опитайте отново по-късно.", "billingCheckoutError": "Грешка при плащане", "billingFailedToGetPortalUrl": "Неуспех при получаване на URL на портала", "billingPortalError": "Грешка в портала", "billingDataUsageInfo": "Таксува се за всички данни, прехвърляни през вашите защитени тунели, когато сте свързани към облака. Това включва както входящия, така и изходящия трафик за всички ваши сайтове. Когато достигнете лимита си, вашите сайтове ще бъдат прекъснати, докато не надстроите плана или не намалите използването. Данните не се таксуват при използване на възли.", "billingSInfo": "Колко сайта можете да използвате", "billingUsersInfo": "Колко потребители можете да използвате", "billingDomainInfo": "Колко домейни можете да използвате", "billingRemoteExitNodesInfo": "Колко дистанционни възли можете да използвате", "billingLicenseKeys": "Лицензионни ключове", "billingLicenseKeysDescription": "Управлявайте вашите абонаменти за лицензионни ключове", "billingLicenseSubscription": "Абонамент за лиценз", "billingInactive": "Неактивен", "billingLicenseItem": "Лицензионен елемент", "billingQuantity": "Количество", "billingTotal": "общо", "billingModifyLicenses": "Промяна на абонамента за лиценз", "domainNotFound": "Домейнът не е намерен", "domainNotFoundDescription": "Този ресурс е деактивиран, защото домейнът вече не съществува в нашата система. Моля, задайте нов домейн за този ресурс.", "failed": "Неуспешно", "createNewOrgDescription": "Създайте нова организация", "organization": "Организация", "primary": "Основно", "port": "Порт", "securityKeyManage": "Управление на ключове за защита", "securityKeyDescription": "Добавяне или премахване на ключове за защита за удостоверяване без парола", "securityKeyRegister": "Регистриране на нов ключ за защита", "securityKeyList": "Вашите ключове за защита", "securityKeyNone": "Все още не са регистрирани ключове за защита", "securityKeyNameRequired": "Името е задължително", "securityKeyRemove": "Премахване", "securityKeyLastUsed": "Последно използван: {date}", "securityKeyNameLabel": "Име на ключа за сигурност", "securityKeyRegisterSuccess": "Ключът за защита е регистриран успешно", "securityKeyRegisterError": "Неуспешна регистрация на ключ за защита", "securityKeyRemoveSuccess": "Ключът за защита е премахнат успешно", "securityKeyRemoveError": "Неуспешно премахване на ключ за защита", "securityKeyLoadError": "Неуспешно зареждане на ключове за защита", "securityKeyLogin": "Използвайте ключ за защита", "securityKeyAuthError": "Неуспешно удостоверяване с ключ за сигурност", "securityKeyRecommendation": "Регистрирайте резервен ключ за безопасност на друго устройство, за да сте сигурни, че винаги ще имате достъп до профила си", "registering": "Регистрация...", "securityKeyPrompt": "Моля, потвърдете самоличността си, използвайки вашия ключ за защита. Уверете се, че е свързан и готов за употреба", "securityKeyBrowserNotSupported": "Вашият браузър не поддържа ключове за сигурност. Моля, използвайте модерен браузър като Chrome, Firefox или Safari.", "securityKeyPermissionDenied": "Моля, позволете достъп до ключа за защита, за да продължите с влизането.", "securityKeyRemovedTooQuickly": "Моля, дръжте ключа си за сигурност свързан, докато процеса на влизане приключи.", "securityKeyNotSupported": "Вашият ключ за сигурност може да не е съвместим. Моля, опитайте с друг ключ.", "securityKeyUnknownError": "Възникна проблем при използването на ключа за сигурност. Моля, опитайте отново.", "twoFactorRequired": "Двуфакторното удостоверяване е необходимо за регистрация на ключ за защита.", "twoFactor": "Двуфакторно удостоверяване", "twoFactorAuthentication": "Двуфакторно удостоверяване", "twoFactorDescription": "Тази организация изисква двуфакторно удостоверяване.", "enableTwoFactor": "Активиране на двуфакторно удостоверяване", "organizationSecurityPolicy": "Политика за сигурност на организацията", "organizationSecurityPolicyDescription": "Тази организация има изисквания за сигурност, които трябва да бъдат изпълнени, за да имате достъп до нея.", "securityRequirements": "Изисквания за сигурност", "allRequirementsMet": "Всички изисквания са изпълнени", "completeRequirementsToContinue": "Завършете изискванията по-долу, за да продължите достъпа до тази организация", "youCanNowAccessOrganization": "Вече можете да получите достъп до тази организация", "reauthenticationRequired": "Продължителност на сесията", "reauthenticationDescription": "Тази организация изисква да влизате на всеки {maxDays} дни.", "reauthenticationDescriptionHours": "Тази организация изисква да влизате на всеки {maxHours} часа.", "reauthenticateNow": "Влезте отново", "adminEnabled2FaOnYourAccount": "Вашият администратор е активирал двуфакторно удостоверяване за {email}. Моля, завършете процеса по настройка, за да продължите.", "securityKeyAdd": "Добавяне на ключ за сигурност", "securityKeyRegisterTitle": "Регистриране на нов ключ за сигурност", "securityKeyRegisterDescription": "Свържете ключа за сигурност и въведете име, по което да го идентифицирате", "securityKeyTwoFactorRequired": "Необходимо е двуфакторно удостоверяване", "securityKeyTwoFactorDescription": "Моля, въведете вашия код за двуфакторно удостоверяване, за да регистрирате ключа за сигурност", "securityKeyTwoFactorRemoveDescription": "Моля, въведете вашия код за двуфакторно удостоверяване, за да премахнете ключа за сигурност", "securityKeyTwoFactorCode": "Двуфакторен код", "securityKeyRemoveTitle": "Премахване на ключ за сигурност", "securityKeyRemoveDescription": "Въведете паролата си, за да премахнете ключа за сигурност “{name}”", "securityKeyNoKeysRegistered": "Няма регистрирани ключове за сигурност", "securityKeyNoKeysDescription": "Добавяне на ключ за сигурност, за да подобрите сигурността на профила си", "createDomainRequired": "Домейнът е задължителен", "createDomainAddDnsRecords": "Добавяне на DNS записи", "createDomainAddDnsRecordsDescription": "Добавете следните DNS записи на вашия домейн провайдер, за да завършите настройката.", "createDomainNsRecords": "NS записи", "createDomainRecord": "Запис", "createDomainType": "Тип:", "createDomainName": "Име:", "createDomainValue": "Стойност:", "createDomainCnameRecords": "CNAME записи", "createDomainARecords": "A записи", "createDomainRecordNumber": "Запис {number}", "createDomainTxtRecords": "TXT записи", "createDomainSaveTheseRecords": "Запазете тези записи", "createDomainSaveTheseRecordsDescription": "Уверете се, че запазвате тези DNS записи, тъй като няма да ги видите отново.", "createDomainDnsPropagation": "Разпространение на DNS", "createDomainDnsPropagationDescription": "Промените в DNS може да отнемат време, за да се разпространят в интернет. Това може да отнеме от няколко минути до 48 часа, в зависимост от вашия DNS доставчик и TTL настройките .", "resourcePortRequired": "Номерът на порта е задължителен за не-HTTP ресурси", "resourcePortNotAllowed": "Номерът на порта не трябва да бъде задаван за HTTP ресурси", "billingPricingCalculatorLink": "Калкулатор на цените", "billingYourPlan": "Вашият план", "billingViewOrModifyPlan": "Преглед или промяна на текущия ви план", "billingViewPlanDetails": "Преглед на подробности за плана", "billingUsageAndLimits": "Използване и граници", "billingViewUsageAndLimits": "Преглед на ограниченията на плана и текущото използване", "billingCurrentUsage": "Текущо използване", "billingMaximumLimits": "Максимални граници", "billingRemoteNodes": "Дистанционни възли", "billingUnlimited": "Неограничено", "billingPaidLicenseKeys": "Платени лицензионни ключове", "billingManageLicenseSubscription": "Управлявайте абонамента си за платени самостоятелно хоствани лицензионни ключове", "billingCurrentKeys": "Текущи ключове", "billingModifyCurrentPlan": "Промяна на текущия план", "billingConfirmUpgrade": "Потвърдете повишаването", "billingConfirmDowngrade": "Потвърдете понижението", "billingConfirmUpgradeDescription": "Предстои ви да повишите плана си. Прегледайте новите ограничения и цени по-долу.", "billingConfirmDowngradeDescription": "Предстои ви да понижите плана си. Прегледайте новите ограничения и цени по-долу.", "billingPlanIncludes": "Планът включва", "billingProcessing": "Процесиране...", "billingConfirmUpgradeButton": "Потвърдете повишаването", "billingConfirmDowngradeButton": "Потвърдете понижението", "billingLimitViolationWarning": "Използването надвишава новите планови ограничения", "billingLimitViolationDescription": "Текущото ви използване надвишава ограниченията на този план. След понижаване, всички действия ще бъдат деактивирани, докато не намалите използването в рамките на новите ограничения. Моля, прегледайте по-долу функциите, които в момента са извън ограниченията. Ограничения в нарушение:", "billingFeatureLossWarning": "Уведомление за наличност на функциите", "billingFeatureLossDescription": "Чрез понижението на плана, функциите, недостъпни в новия план, ще бъдат автоматично деактивирани. Някои настройки и конфигурации може да бъдат загубени. Моля, прегледайте ценовата матрица, за да разберете кои функции вече няма да са на разположение.", "billingUsageExceedsLimit": "Текущото използване ({current}) надвишава ограничението ({limit})", "billingPastDueTitle": "Плащането е просрочено", "billingPastDueDescription": "Вашето плащане е просрочено. Моля, актуализирайте метода на плащане, за да продължите да използвате настоящия си план. Ако проблемът не бъде разрешен, абонаментът ви ще бъде прекратен и ще бъдете прехвърлени на безплатния план.", "billingUnpaidTitle": "Абонаментът не е платен", "billingUnpaidDescription": "Вашият абонамент не е платен и сте прехвърлени на безплатния план. Моля, актуализирайте метода на плащане, за да възстановите вашия абонамент.", "billingIncompleteTitle": "Плащането е непълно", "billingIncompleteDescription": "Вашето плащане е непълно. Моля, завършете процеса на плащане, за да активирате вашия абонамент.", "billingIncompleteExpiredTitle": "Плащането е изтекло", "billingIncompleteExpiredDescription": "Вашето плащане никога не е завършено и е изтекло. Прехвърлени сте на безплатния план. Моля, абонирайте се отново, за да възстановите достъпа до платените функции.", "billingManageSubscription": "Управлявайте вашия абонамент", "billingResolvePaymentIssue": "Моля, разрешете проблема с плащането преди да извършите надграждане или понижение", "signUpTerms": { "IAgreeToThe": "Съгласен съм с", "termsOfService": "условията за ползване", "and": "и", "privacyPolicy": "политика за поверителност." }, "signUpMarketing": { "keepMeInTheLoop": "Дръж ме в течение с новини, актуализации и нови функции чрез имейл." }, "siteRequired": "Изисква се сайт.", "olmTunnel": "Olm тунел", "olmTunnelDescription": "Използвайте Olm за клиентска свързаност", "errorCreatingClient": "Възникна грешка при създаване на клиент", "clientDefaultsNotFound": "Не са намерени настройки по подразбиране за клиента", "createClient": "Създаване на клиент", "createClientDescription": "Създайте нов клиент за достъп до частни ресурси", "seeAllClients": "Виж всички клиенти", "clientInformation": "Информация за клиента", "clientNamePlaceholder": "Име на клиента", "address": "Адрес", "subnetPlaceholder": "Мрежа", "addressDescription": "Вътрешният адрес на клиента. Трябва да пада в подмрежата на организацията.", "selectSites": "Избор на сайтове", "sitesDescription": "Клиентът ще има връзка с избраните сайтове", "clientInstallOlm": "Инсталиране на Olm", "clientInstallOlmDescription": "Конфигурирайте Olm да работи на вашата система", "clientOlmCredentials": "Удостоверителни данни", "clientOlmCredentialsDescription": "Това е начинът, по който клиентът ще се удостоверява със сървъра", "olmEndpoint": "Крайна точка", "olmId": "Идентификационен номер", "olmSecretKey": "Секретен ключ", "clientCredentialsSave": "Запазете удостоверителните данни", "clientCredentialsSaveDescription": "Ще можете да го видите само веднъж. Уверете се, че ще го копирате на сигурно място.", "generalSettingsDescription": "Конфигурирайте общите настройки за този клиент", "clientUpdated": "Клиентът актуализиран", "clientUpdatedDescription": "Клиентът беше актуализиран.", "clientUpdateFailed": "Актуализацията на клиента неуспешна", "clientUpdateError": "Възникна грешка по време на актуализацията на клиента.", "sitesFetchFailed": "Неуспешно получаване на сайтове", "sitesFetchError": "Възникна грешка при получаването на сайтовете.", "olmErrorFetchReleases": "Възникна грешка при получаването на Olm версиите.", "olmErrorFetchLatest": "Възникна грешка при получаването на последната версия на Olm.", "enterCidrRange": "Въведете CIDR обхват", "resourceEnableProxy": "Разрешаване на публичен прокси", "resourceEnableProxyDescription": "Разрешете публично проксиране на този ресурс. Това позволява достъп до ресурса извън мрежата чрез облак на отворен порт. Изисква конфигурация на Traefik.", "externalProxyEnabled": "Външен прокси разрешен", "addNewTarget": "Добави нова цел", "targetsList": "Списък с цели", "advancedMode": "Разширен режим", "advancedSettings": "Разширени настройки.", "targetErrorDuplicateTargetFound": "Дублирана цел намерена", "healthCheckHealthy": "Здрав", "healthCheckUnhealthy": "Нездрав", "healthCheckUnknown": "Неизвестен", "healthCheck": "Проверка на здравето", "configureHealthCheck": "Конфигуриране на проверка на здравето", "configureHealthCheckDescription": "Настройте мониторинг на здравето за {target}", "enableHealthChecks": "Разрешаване на проверки на здравето", "enableHealthChecksDescription": "Мониторинг на здравето на тази цел. Можете да наблюдавате различен краен пункт от целта, ако е необходимо.", "healthScheme": "Метод", "healthSelectScheme": "Избор на метод", "healthCheckPortInvalid": "Портът за проверка на състоянието трябва да е между 1 и 65535", "healthCheckPath": "Път", "healthHostname": "IP / Хост", "healthPort": "Порт", "healthCheckPathDescription": "Пътят за проверка на здравното състояние.", "healthyIntervalSeconds": "Интервал на здраве (сек)", "unhealthyIntervalSeconds": "Интервал на нездраве (сек)", "IntervalSeconds": "Интервал за здраве", "timeoutSeconds": "Време за изчакване (сек)", "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 се счита за здравословно.", "customHeaders": "Персонализирани заглавия", "customHeadersDescription": "Add custom headers to be sent when proxying requests. One per line in the format Header-Name: value", "headersValidationError": "Заглавията трябва да бъдат във формат: Име на заглавието: стойност.", "saveHealthCheck": "Запазване на проверка на здравето", "healthCheckSaved": "Проверка на здравето е запазена", "healthCheckSavedDescription": "Конфигурацията на здравната проверка е запазена успешно", "healthCheckError": "Грешка при проверката на здравето", "healthCheckErrorDescription": "Възникна грешка при запазването на конфигурацията за проверка на здравето", "healthCheckPathRequired": "Изисква се път за проверка на здравето", "healthCheckMethodRequired": "Изисква се HTTP метод", "healthCheckIntervalMin": "Интервалът за проверка трябва да е поне 5 секунди", "healthCheckTimeoutMin": "Времето за изчакване трябва да е поне 1 секунда", "healthCheckRetryMin": "Опитите за повторение трябва да са поне 1", "httpMethod": "HTTP Метод", "selectHttpMethod": "Изберете HTTP метод", "domainPickerSubdomainLabel": "Поддомен", "domainPickerBaseDomainLabel": "Основен домейн", "domainPickerSearchDomains": "Търсене на домейни...", "domainPickerNoDomainsFound": "Не са намерени домейни", "domainPickerLoadingDomains": "Зареждане на домейни...", "domainPickerSelectBaseDomain": "Изберете основен домейн...", "domainPickerNotAvailableForCname": "Не е налично за CNAME домейни", "domainPickerEnterSubdomainOrLeaveBlank": "Въведете поддомен или оставете празно, за да използвате основния домейн.", "domainPickerEnterSubdomainToSearch": "Въведете поддомен, за да търсите и изберете от наличните свободни домейни.", "domainPickerFreeDomains": "Безплатни домейни", "domainPickerSearchForAvailableDomains": "Търсене за налични домейни", "domainPickerNotWorkSelfHosted": "Забележка: Безплатните предоставени домейни не са налични за самостоятелно хоствани инстанции в момента.", "resourceDomain": "Домейн", "resourceEditDomain": "Редактиране на домейн", "siteName": "Име на сайта", "proxyPort": "Порт", "resourcesTableProxyResources": "Публичен", "resourcesTableClientResources": "Частен", "resourcesTableNoProxyResourcesFound": "Не са намерени ресурсни проксита.", "resourcesTableNoInternalResourcesFound": "Не са намерени вътрешни ресурси.", "resourcesTableDestination": "Дестинация", "resourcesTableAlias": "Псевдоним", "resourcesTableAliasAddress": "Адрес на псевдоним.", "resourcesTableAliasAddressInfo": "Този адрес е част от подсистемата на организацията. Използва се за разрешаване на псевдонимни записи чрез вътрешно DNS разрешаване.", "resourcesTableClients": "Клиенти", "resourcesTableAndOnlyAccessibleInternally": "и са достъпни само вътрешно при свързване с клиент.", "resourcesTableNoTargets": "Без цели", "resourcesTableHealthy": "Здрав", "resourcesTableDegraded": "Влошен", "resourcesTableOffline": "Извън линия", "resourcesTableUnknown": "Неизвестно", "resourcesTableNotMonitored": "Не е наблюдавано", "editInternalResourceDialogEditClientResource": "Редактиране на частен ресурс", "editInternalResourceDialogUpdateResourceProperties": "Актуализирайте конфигурацията на ресурса и контрола на достъпа за {resourceName}", "editInternalResourceDialogResourceProperties": "Свойствата на ресурса", "editInternalResourceDialogName": "Име", "editInternalResourceDialogProtocol": "Протокол", "editInternalResourceDialogSitePort": "Сайт Порт", "editInternalResourceDialogTargetConfiguration": "Конфигурация на целите", "editInternalResourceDialogCancel": "Отмяна", "editInternalResourceDialogSaveResource": "Запазване на ресурс", "editInternalResourceDialogSuccess": "Успех", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Вътрешният ресурс успешно актуализиран", "editInternalResourceDialogError": "Грешка", "editInternalResourceDialogFailedToUpdateInternalResource": "Неуспешно актуализиране на вътрешен ресурс", "editInternalResourceDialogNameRequired": "Името е задължително", "editInternalResourceDialogNameMaxLength": "Името трябва да е по-малко от 255 символа", "editInternalResourceDialogProxyPortMin": "Прокси портът трябва да бъде поне 1", "editInternalResourceDialogProxyPortMax": "Прокси портът трябва да е по-малък от 65536", "editInternalResourceDialogInvalidIPAddressFormat": "Невалиден формат на IP адрес", "editInternalResourceDialogDestinationPortMin": "Дестинационният порт трябва да бъде поне 1", "editInternalResourceDialogDestinationPortMax": "Дестинационният порт трябва да е по-малък от 65536", "editInternalResourceDialogPortModeRequired": "За порт режим се изискват протокол, прокси порт и порт на дестинация", "editInternalResourceDialogMode": "Режим", "editInternalResourceDialogModePort": "Порт", "editInternalResourceDialogModeHost": "Хост", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Дестинация", "editInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.", "editInternalResourceDialogDestinationIPDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.", "editInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.", "editInternalResourceDialogAlias": "Псевдоним", "editInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.", "createInternalResourceDialogNoSitesAvailable": "Няма достъпни сайтове", "createInternalResourceDialogNoSitesAvailableDescription": "Трябва да имате поне един сайт на Newt с конфигурирана мрежа, за да създадете вътрешни ресурси.", "createInternalResourceDialogClose": "Затвори", "createInternalResourceDialogCreateClientResource": "Създаване на частен ресурс", "createInternalResourceDialogCreateClientResourceDescription": "Създайте нов ресурс, който ще бъде достъпен само за клиенти, свързани към организацията", "createInternalResourceDialogResourceProperties": "Свойства на ресурса", "createInternalResourceDialogName": "Име", "createInternalResourceDialogSite": "Сайт", "selectSite": "Изберете сайт...", "noSitesFound": "Не са намерени сайтове.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Сайт Порт", "createInternalResourceDialogSitePortDescription": "Използвайте този порт за достъп до ресурса на сайта при свързване с клиент.", "createInternalResourceDialogTargetConfiguration": "Конфигурация на целите", "createInternalResourceDialogDestinationIPDescription": "IP или хостният адрес на ресурса в мрежата на сайта.", "createInternalResourceDialogDestinationPortDescription": "Портът на дестинационния IP, където ресурсът е достъпен.", "createInternalResourceDialogCancel": "Отмяна", "createInternalResourceDialogCreateResource": "Създаване на ресурс", "createInternalResourceDialogSuccess": "Успех", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Вътрешният ресурс създаден успешно", "createInternalResourceDialogError": "Грешка", "createInternalResourceDialogFailedToCreateInternalResource": "Неуспешно създаване на вътрешен ресурс", "createInternalResourceDialogNameRequired": "Името е задължително", "createInternalResourceDialogNameMaxLength": "Името трябва да е по-малко от 255 символа", "createInternalResourceDialogPleaseSelectSite": "Моля, изберете сайт", "createInternalResourceDialogProxyPortMin": "Прокси портът трябва да бъде поне 1", "createInternalResourceDialogProxyPortMax": "Прокси портът трябва да е по-малък от 65536", "createInternalResourceDialogInvalidIPAddressFormat": "Невалиден формат на IP адрес", "createInternalResourceDialogDestinationPortMin": "Дестинационният порт трябва да бъде поне 1", "createInternalResourceDialogDestinationPortMax": "Дестинационният порт трябва да е по-малък от 65536", "createInternalResourceDialogPortModeRequired": "За порт режим се изискват протокол, прокси порт и порт на дестинация", "createInternalResourceDialogMode": "Режим", "createInternalResourceDialogModePort": "Порт", "createInternalResourceDialogModeHost": "Хост", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Дестинация", "createInternalResourceDialogDestinationHostDescription": "IP адресът или името на хоста на ресурса в мрежата на сайта.", "createInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.", "createInternalResourceDialogAlias": "Псевдоним", "createInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.", "siteConfiguration": "Конфигурация", "siteAcceptClientConnections": "Приемане на клиентски връзки", "siteAcceptClientConnectionsDescription": "Позволете на потребителските устройства и клиенти да получават достъп до ресурси на този сайт. Това може да бъде променено по-късно.", "siteAddress": "Адрес на сайта (Разширено)", "siteAddressDescription": "Вътрешният адрес на сайта. Трябва да пада в подмрежата на организацията.", "siteNameDescription": "Показваното име на сайта, което може да се промени по-късно.", "autoLoginExternalIdp": "Автоматично влизане с Външен IDP", "autoLoginExternalIdpDescription": "Незабавно пренасочване на потребителя към външния доставчик на идентичност за автентификация.", "selectIdp": "Изберете IDP", "selectIdpPlaceholder": "Изберете IDP...", "selectIdpRequired": "Моля, изберете IDP, когато автоматичното влизане е разрешено.", "autoLoginTitle": "Пренасочване", "autoLoginDescription": "Пренасочване към външния доставчик на идентификационни данни за удостоверяване.", "autoLoginProcessing": "Подготовка за удостоверяване...", "autoLoginRedirecting": "Пренасочване към вход...", "autoLoginError": "Грешка при автоматично влизане", "autoLoginErrorNoRedirectUrl": "Не е получен URL за пренасочване от доставчика на идентификационни данни.", "autoLoginErrorGeneratingUrl": "Неуспешно генериране на URL за удостоверяване.", "remoteExitNodeManageRemoteExitNodes": "Отдалечени възли", "remoteExitNodeDescription": "Хоствайте вашите собствени отдалечени ретранслатори и прокси сървърни възли.", "remoteExitNodes": "Възли", "searchRemoteExitNodes": "Търсене на възли...", "remoteExitNodeAdd": "Добавяне на възел", "remoteExitNodeErrorDelete": "Грешка при изтриване на възел", "remoteExitNodeQuestionRemove": "Сигурни ли сте, че искате да премахнете възела от организацията?", "remoteExitNodeMessageRemove": "След премахване, възелът вече няма да бъде достъпен.", "remoteExitNodeConfirmDelete": "Потвърдете изтриването на възела (\"Confirm Delete Site\" match)", "remoteExitNodeDelete": "Изтрийте възела (\"Delete Site\" match)", "sidebarRemoteExitNodes": "Отдалечени възли", "remoteExitNodeId": "ID.", "remoteExitNodeSecretKey": "Секретен ключ.", "remoteExitNodeCreate": { "title": "Създаване на отдалечен възел.", "description": "Създайте нов самохостнал отдалечен ретранслатор и прокси сървърен възел.", "viewAllButton": "Вижте всички възли", "strategy": { "title": "Стратегия на създаване", "description": "Изберете как искате да създадете отдалечения възел.", "adopt": { "title": "Осиновете възел", "description": "Изберете това, ако вече имате кредити за възела." }, "generate": { "title": "Генериране на ключове", "description": "Изберете това, ако искате да генерирате нови ключове за възела." } }, "adopt": { "title": "Осиновяване на съществуващ възел", "description": "Въведете данните на съществуващия възел, който искате да осиновите", "nodeIdLabel": "ID на възела", "nodeIdDescription": "ID на съществуващия възел, който искате да осиновите", "secretLabel": "Секретен", "secretDescription": "Секретният ключ на съществуващия възел", "submitButton": "Осиновете възела" }, "generate": { "title": "Генерирани кредити", "description": "Използвайте тези генерирани идентификационни данни за конфигуриране на възела", "nodeIdTitle": "ID на възела", "secretTitle": "Секретен", "saveCredentialsTitle": "Добавете кредити към конфигурацията", "saveCredentialsDescription": "Добавете тези кредити към конфигурационния файл на вашия самостоятелно хостван Pangolin възел, за да завършите връзката.", "submitButton": "Създаване на възел" }, "validation": { "adoptRequired": "ID на възела и секрет са необходими при осиновяване на съществуващ възел" }, "errors": { "loadDefaultsFailed": "Грешка при зареждане на подразбирани настройки", "defaultsNotLoaded": "Подразбирани настройки не са заредени", "createFailed": "Грешка при създаване на възел" }, "success": { "created": "Възелът е създаден успешно" } }, "remoteExitNodeSelection": "Избор на възел", "remoteExitNodeSelectionDescription": "Изберете възел, през който да пренасочвате трафика за местния сайт", "remoteExitNodeRequired": "Необходимо е да бъде избран възел за местни сайтове", "noRemoteExitNodesAvailable": "Няма налични възли", "noRemoteExitNodesAvailableDescription": "Няма налични възли за тази организация. Първо създайте възел, за да използвате местни сайтове.", "exitNode": "Изходен възел", "country": "Държава", "rulesMatchCountry": "Понастоящем на базата на изходния IP", "managedSelfHosted": { "title": "Управлявано Самостоятелно-хоствано", "description": "По-надежден и по-нисък поддръжка на Самостоятелно-хостван Панголиин сървър с допълнителни екстри", "introTitle": "Управлявано Самостоятелно-хостван Панголиин", "introDescription": "е опция за внедряване, предназначена за хора, които искат простота и допълнителна надеждност, като същевременно запазят данните си частни и самостоятелно-хоствани.", "introDetail": "С тази опция все още управлявате свой собствен Панголиин възел — вашите тунели, SSL терминатора и трафик остават на вашия сървър. Разликата е, че управлението и мониторингът се обработват чрез нашия облачен панел за контрол, който отключва редица предимства:", "benefitSimplerOperations": { "title": "По-прости операции", "description": "Няма нужда да управлявате свой собствен имейл сървър или да настройвате сложни аларми. Ще получите проверки и предупреждения при прекъсване от самото начало." }, "benefitAutomaticUpdates": { "title": "Автоматични актуализации", "description": "Облачният панел за контрол се развива бързо, така че получавате нови функции и корекции на грешки без да се налага да извличате нови контейнери всеки път." }, "benefitLessMaintenance": { "title": "По-малко поддръжка", "description": "Няма миграции на база от данни, резервни копия или допълнителна инфраструктура за управление. Ние се грижим за това в облака." }, "benefitCloudFailover": { "title": "Облачно преобръщане", "description": "Ако вашият възел спре да работи, вашите тунели могат временно да преориентират към нашите облачни точки, докато не го възстановите." }, "benefitHighAvailability": { "title": "Висока наличност (PoPs)", "description": "Можете също така да прикрепите множество възли към вашия акаунт за резервно копиране и по-добра производителност." }, "benefitFutureEnhancements": { "title": "Бъдещи подобрения", "description": "Планираме да добавим още аналитични, алармиращи и управителни инструменти, за да направим вашето внедряване още по-здраво." }, "docsAlert": { "text": "Научете повече за Управляваното Самостоятелно-хоствано опцията в нашата", "documentation": "документация" }, "convertButton": "Конвертирайте този възел в Управлявано Самостоятелно-хоствано" }, "internationaldomaindetected": "Открит международен домейн", "willbestoredas": "Ще бъде съхранено като:", "roleMappingDescription": "Определете как се разпределят ролите на потребителите при вписване, когато е активирано автоматично предоставяне.", "selectRole": "Избор на роля", "roleMappingExpression": "Израз", "selectRolePlaceholder": "Избор на роля", "selectRoleDescription": "Изберете роля за присвояване на всички потребители от този доставчик на идентичност", "roleMappingExpressionDescription": "Въведете израз JMESPath, за да извлечете информация за ролята от ID токена", "idpTenantIdRequired": "Изисква се идентификационен номер на наемателя", "invalidValue": "Невалидна стойност", "idpTypeLabel": "Тип на доставчика на идентичност", "roleMappingExpressionPlaceholder": "напр.: contains(groups, 'admin') && 'Admin' || 'Member'", "idpGoogleConfiguration": "Конфигурация на Google", "idpGoogleConfigurationDescription": "Конфигурирайте Google OAuth2 идентификационни данни", "idpGoogleClientIdDescription": "Google OAuth2 идентификационен клиент", "idpGoogleClientSecretDescription": "Google OAuth2 секретен клиент", "idpAzureConfiguration": "Конфигурация на Azure Entra ID", "idpAzureConfigurationDescription": "Конфигурирайте OAuth2 идентификационни данни на Azure Entra ID", "idpTenantId": "Идентификационен номер на наемателя", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "Идентификационен номер на наемателя на Azure (намира се в прегледа на Azure Active Directory)", "idpAzureClientIdDescription": "Идентификационен код на клиента за регистриране на приложение в Azure", "idpAzureClientSecretDescription": "Секретен код на клиента за регистриране на приложение в Azure", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Конфигурация на Google", "idpAzureConfigurationTitle": "Конфигурация на Azure Entra ID", "idpTenantIdLabel": "Идентификационен номер на наемателя", "idpAzureClientIdDescription2": "Идентификационен код на клиента за регистриране на приложение в Azure", "idpAzureClientSecretDescription2": "Секретен код на клиента за регистриране на приложение в Azure", "idpGoogleDescription": "Google OAuth2/OIDC доставчик", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC доставчик", "subnet": "Подмрежа", "subnetDescription": "Подмрежата за конфигурацията на мрежата на тази организация.", "customDomain": "Персонализиран домейн.", "authPage": "Страници за автентификация.", "authPageDescription": "Задайте персонализиран домейн за страниците за автентификация на организацията.", "authPageDomain": "Домен на страницата за удостоверяване", "authPageBranding": "Персонализиран брандинг.", "authPageBrandingDescription": "Конфигурирайте брандинга, който се появява на страниците за автентификация за тази организация.", "authPageBrandingUpdated": "Брандингът на страницата за автентификация е актуализиран успешно.", "authPageBrandingRemoved": "Брандингът на страницата за автентификация е премахнат успешно.", "authPageBrandingRemoveTitle": "Премахване на брандинга на страницата за автентификация.", "authPageBrandingQuestionRemove": "Сигурни ли сте, че искате да премахнете брандинга за страниците за автентификация?", "authPageBrandingDeleteConfirm": "Потвърждение на изтриване на брандинга.", "brandingLogoURL": "URL адрес на логото.", "brandingLogoURLOrPath": "URL или Път към лого", "brandingLogoPathDescription": "Въведете URL или локален път.", "brandingLogoURLDescription": "Въведете публично достъпен URL към вашето лого изображение.", "brandingPrimaryColor": "Основен цвят.", "brandingLogoWidth": "Ширина (px).", "brandingLogoHeight": "Височина (px).", "brandingOrgTitle": "Заглавие за страницата за автентификация на организацията.", "brandingOrgDescription": "{orgName} ще бъде заменено с името на организацията.", "brandingOrgSubtitle": "Подзаглавие за страницата за автентификация на организацията.", "brandingResourceTitle": "Заглавие за страницата за автентификация на ресурса.", "brandingResourceSubtitle": "Подзаглавие за страницата за автентификация на ресурса.", "brandingResourceDescription": "{resourceName} ще бъде заменено с името на организацията.", "saveAuthPageDomain": "Запазване на домейна.", "saveAuthPageBranding": "Запазване на брандинга.", "removeAuthPageBranding": "Премахване на брандинга.", "noDomainSet": "Няма зададен домейн", "changeDomain": "Смяна на домейн", "selectDomain": "Избор на домейн", "restartCertificate": "Рестартиране на сертификат", "editAuthPageDomain": "Редактиране на домейна на страницата за удостоверяване", "setAuthPageDomain": "Задаване на домейн на страницата за удостоверяване", "failedToFetchCertificate": "Неуспех при извличане на сертификат", "failedToRestartCertificate": "Неуспех при рестартиране на сертификат", "addDomainToEnableCustomAuthPages": "Потребителите ще имат достъп до страницата за вход на организацията и ще завършат автентификацията на ресурси, като използват този домейн.", "selectDomainForOrgAuthPage": "Изберете домейн за страницата за удостоверяване на организацията", "domainPickerProvidedDomain": "Предоставен домейн", "domainPickerFreeProvidedDomain": "Безплатен предоставен домейн", "domainPickerVerified": "Проверено", "domainPickerUnverified": "Непроверено", "domainPickerInvalidSubdomainStructure": "Този поддомен съдържа невалидни знаци или структура. Ще бъде автоматично пречистен при запазване.", "domainPickerError": "Грешка", "domainPickerErrorLoadDomains": "Неуспешно зареждане на домейни на организацията", "domainPickerErrorCheckAvailability": "Неуспешна проверка на наличността на домейни", "domainPickerInvalidSubdomain": "Невалиден поддомен", "domainPickerInvalidSubdomainRemoved": "Входът \"{sub}\" беше премахнат, защото не е валиден.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" не може да се направи валиден за {domain}.", "domainPickerSubdomainSanitized": "Поддомен пречистен", "domainPickerSubdomainCorrected": "\"{sub}\" беше коригиран на \"{sanitized}\"", "orgAuthSignInTitle": "Вход в организация.", "orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите", "orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.", "orgAuthSignInWithPangolin": "Впишете се с Pangolin", "orgAuthSignInToOrg": "Влезте в организация", "orgAuthSelectOrgTitle": "Вход в организация.", "orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.", "orgAuthOrgIdPlaceholder": "вашата-организация", "orgAuthOrgIdHelp": "Въведете уникалния идентификатор на вашата организация.", "orgAuthSelectOrgHelp": "След като въведете идентификатора на организацията си, ще бъдете насочени към страницата за вход на вашата организация, където можете да използвате SSO или вашите организационни удостоверения.", "orgAuthRememberOrgId": "Запомнете този идентификатор на организацията.", "orgAuthBackToSignIn": "Назад към стандартния вход.", "orgAuthNoAccount": "Нямате профил?", "subscriptionRequiredToUse": "Необходим е абонамент, за да използвате тази функция.", "mustUpgradeToUse": "Трябва да повишите своя абонамент, за да използвате тази функция.", "subscriptionRequiredTierToUse": "Тази функция изисква {tier} или по-висок план.", "upgradeToTierToUse": "Повишете до {tier} или по-висок план, за да използвате тази функция.", "subscriptionTierTier1": "Домашен", "subscriptionTierTier2": "Екип", "subscriptionTierTier3": "Бизнес", "subscriptionTierEnterprise": "Предприятие", "idpDisabled": "Доставчиците на идентичност са деактивирани.", "orgAuthPageDisabled": "Страницата за удостоверяване на организацията е деактивирана.", "domainRestartedDescription": "Проверка на домейна е рестартирана успешно", "resourceAddEntrypointsEditFile": "Редактиране на файл: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Редактиране на файл: docker-compose.yml", "emailVerificationRequired": "Потвърждението на Email е необходимо. Моля, влезте отново чрез {dashboardUrl}/auth/login, за да завършите тази стъпка. След това, върнете се тук.", "twoFactorSetupRequired": "Необходима е настройка на двуфакторно удостоверяване. Моля, влезте отново чрез {dashboardUrl}/auth/login, за да завършите тази стъпка. След това, върнете се тук.", "additionalSecurityRequired": "Необходима е допълнителна сигурност", "organizationRequiresAdditionalSteps": "Тази организация изисква допълнителни стъпки за сигурност, за да получите достъп до ресурсите.", "completeTheseSteps": "Завършете тези стъпки", "enableTwoFactorAuthentication": "Активирайте двуфакторното удостоверяване", "completeSecuritySteps": "Завършете стъпките за сигурност", "securitySettings": "Настройки за сигурност", "dangerSection": "Зона на опасност.", "dangerSectionDescription": "Премахване на всички данни, свързани с тази организация.", "securitySettingsDescription": "Конфигурирайте политики за сигурност за организацията", "requireTwoFactorForAllUsers": "Изисквайте двуфакторно удостоверяване за всички потребители", "requireTwoFactorDescription": "Когато е активирано, всички вътрешни потребители в организацията трябва да имат активирано двуфакторно удостоверяване, за да имат достъп до организацията.", "requireTwoFactorDisabledDescription": "Тази функция изисква валиден лиценз (Enterprise) или активен абонамент (SaaS).", "requireTwoFactorCannotEnableDescription": "Трябва да активирате двуфакторното удостоверяване за вашия акаунт, преди да го наложите за всички потребители.", "maxSessionLength": "Максимална продължителност на сесията", "maxSessionLengthDescription": "Задайте максималната продължителност на потребителските сесии. След това време потребителите ще трябва да се удостоверят отново.", "maxSessionLengthDisabledDescription": "Тази функция изисква валиден лиценз (Enterprise) или активен абонамент (SaaS).", "selectSessionLength": "Изберете продължителност на сесията", "unenforced": "Неналожено", "1Hour": "1 час", "3Hours": "3 часа", "6Hours": "6 часа", "12Hours": "12 часа", "1DaySession": "1 ден", "3Days": "3 дни", "7Days": "7 дни", "14Days": "14 дни", "30DaysSession": "30 дни", "90DaysSession": "90 дни", "180DaysSession": "180 дни", "passwordExpiryDays": "Изтичане на парола", "editPasswordExpiryDescription": "Задайте броя дни, след които потребителите трябва да сменят паролата си.", "selectPasswordExpiry": "Изберете изтичането на парола", "30Days": "30 дни", "1Day": "1 ден", "60Days": "60 дни", "90Days": "90 дни", "180Days": "180 дни", "1Year": "1 година", "subscriptionBadge": "Изисква се абонамент", "securityPolicyChangeWarning": "Предупреждение за промяна на политиката за сигурност", "securityPolicyChangeDescription": "Ще променяте настройките на политиката за сигурност. След запазване може да се наложи да се удостоверите отново, за да се съобразите с тези актуализации. Всички потребители, които не са съвместими, също ще трябва да се удостоверят отново.", "securityPolicyChangeConfirmMessage": "Потвърждавам", "securityPolicyChangeWarningText": "Това ще засегне всички потребители в организацията", "authPageErrorUpdateMessage": "Възникна грешка при актуализирането на настройките на страницата за удостоверяване", "authPageErrorUpdate": "Неуспешно актуализиране на страницата за удостоверяване", "authPageDomainUpdated": "Домейнът на страницата за автентификация е актуализиран успешно.", "healthCheckNotAvailable": "Локална", "rewritePath": "Пренапиши път", "rewritePathDescription": "По избор пренапиши пътя преди пренасочване към целта.", "continueToApplication": "Продължете до приложението", "checkingInvite": "Проверка на поканата", "setResourceHeaderAuth": "setResourceHeaderAuth", "resourceHeaderAuthRemove": "Премахване на автентикация в заглавката", "resourceHeaderAuthRemoveDescription": "Автентикацията в заглавката беше премахната успешно.", "resourceErrorHeaderAuthRemove": "Неуспешно премахване на автентикация в заглавката", "resourceErrorHeaderAuthRemoveDescription": "Не беше възможно премахването на автентикацията в заглавката за ресурса.", "resourceHeaderAuthProtectionEnabled": "Активирано удостоверяване чрез заглавие", "resourceHeaderAuthProtectionDisabled": "Деактивирано удостоверяване чрез заглавие", "headerAuthRemove": "Премахни удостоверяване чрез заглавие", "headerAuthAdd": "Добави удостоверяване чрез заглавие", "resourceErrorHeaderAuthSetup": "Неуспешно задаване на автентикация в заглавката", "resourceErrorHeaderAuthSetupDescription": "Не беше възможно задаването на автентикация в заглавката за ресурса.", "resourceHeaderAuthSetup": "Автентикацията в заглавката беше зададена успешно", "resourceHeaderAuthSetupDescription": "Автентикацията в заглавката беше успешно зададена.", "resourceHeaderAuthSetupTitle": "Задаване на автентикация в заглавката", "resourceHeaderAuthSetupTitleDescription": "Задайте базови идентификационни данни (потребителско име и парола), за да защитите този ресурс чрез HTTP удостоверяване чрез заглавие. Достъпете до него, използвайки формата https://потребител:парола@ресурс.example.com", "resourceHeaderAuthSubmit": "Задаване на автентикация в заглавката", "actionSetResourceHeaderAuth": "Задаване на автентикация в заглавката", "enterpriseEdition": "Корпоративно издание", "unlicensed": "Без лиценз", "beta": "Бета", "manageUserDevices": "Потребителски устройства", "manageUserDevicesDescription": "Прегледайте и управлявайте устройства, които потребителите използват за поверително свързване към ресурси", "downloadClientBannerTitle": "Изтеглете Pangolin клиент.", "downloadClientBannerDescription": "Изтеглете Pangolin клиента за вашата система, за да се свържете към мрежата Pangolin и да получите достъп до ресурси частно.", "manageMachineClients": "Управлявайте машинни клиенти", "manageMachineClientsDescription": "Създавайте и управлявайте клиенти, които сървърите и системите използват за поверително свързване към ресурси", "machineClientsBannerTitle": "Сървъри и автоматизирани системи.", "machineClientsBannerDescription": "Машинните клиенти са за сървъри и автоматизирани системи, които не са свързани с конкретен потребител. Те се автентифицират с ID и секретен ключ и могат да работят с Pangolin CLI, Olm CLI или Olm като контейнер.", "machineClientsBannerPangolinCLI": "Pangolin CLI.", "machineClientsBannerOlmCLI": "Olm CLI.", "machineClientsBannerOlmContainer": "Olm Контейнер.", "clientsTableUserClients": "Потребител", "clientsTableMachineClients": "Машина", "licenseTableValidUntil": "Валиден до", "saasLicenseKeysSettingsTitle": "Корпоративни лицензи", "saasLicenseKeysSettingsDescription": "Генериране и управление на корпоративни лицензионни ключове за самостоятелно хоствани инстанции на Pangolin", "sidebarEnterpriseLicenses": "Лицензи", "generateLicenseKey": "Генерация на лицензионен ключ", "generateLicenseKeyForm": { "validation": { "emailRequired": "Моля въведете валиден имейл адрес", "useCaseTypeRequired": "Моля изберете тип на употреба", "firstNameRequired": "Името е задължително", "lastNameRequired": "Фамилията е задължителна", "primaryUseRequired": "Моля опишете основната си употреба", "jobTitleRequiredBusiness": "Позицията е задължителна за бизнес изполване", "industryRequiredBusiness": "Индустрията е задължителна за бизнес изполване", "stateProvinceRegionRequired": "Държава/Област/Регион е задължително", "postalZipCodeRequired": "Пощенски/ЗИП Код е задължителен", "companyNameRequiredBusiness": "Фирменото име е задължително за бизнес изполване", "countryOfResidenceRequiredBusiness": "Държавата на пребиваване е задължителна за бизнес изполване", "countryRequiredPersonal": "Държавата е задължителна за лична употреба", "agreeToTermsRequired": "Трябва да се съгласите с условията", "complianceConfirmationRequired": "Трябва да потвърдите съответствието с Fossorial Commercial License" }, "useCaseOptions": { "personal": { "title": "Лична употреба", "description": "За индивидуална, некомерсиална употреба като учене, лични проекти или експериментиране." }, "business": { "title": "Бизнес употреба", "description": "За вътрешно използване във фирми, компании или комерсиални или доходоносни дейности." } }, "steps": { "emailLicenseType": { "title": "Имейл и тип лиценз", "description": "Въведете своя имейл и изберете вид на лиценза" }, "personalInformation": { "title": "Лична информация", "description": "Разкажете ни за себе си" }, "contactInformation": { "title": "Контактна информация", "description": "Вашите контактни данни" }, "termsGenerate": { "title": "Условия и генериране", "description": "Прегледайте и приемете условията, за да генерирате своя лиценз" } }, "alerts": { "commercialUseDisclosure": { "title": "Разкриване на употреба", "description": "Изберете лицензионен клас, който точно отразява вашата целена употреба. Персоналният лиценз позволява безплатно ползване на софтуера за индивидуална, некомерсиална или маломащабна комерсиална дейност с годишен брутен приход под 100,000 USD. Всяко ползване извън тези граници — включително ползване във фирма, организация или друга доходоносна среда — изисква валиден корпоративен лиценз и плащане на съответната лицензионна такса. Всички потребители, независимо дали са лични или корпоративни, трябва да спазват Условията на Fossorial Commercial License." }, "trialPeriodInformation": { "title": "Информация за пробен период", "description": "Този лицензионен ключ предоставя функции на Enterprise за 7-дневен пробен период. За продължен достъп до платени функции след изтичането на пробния период е необходима активация под валиден персонален или корпоративен лиценз. За корпоративно лицензиране се свържете с sales@pangolin.net." } }, "form": { "useCaseQuestion": "Използвате ли Pangolin за лична или бизнес употреба?", "firstName": "Име", "lastName": "Фамилия", "jobTitle": "Позиция", "primaryUseQuestion": "Каква е основната ви цел да използвате Pangolin?", "industryQuestion": "Какъв е вашият отрасъл?", "prospectiveUsersQuestion": "Колко потенциални потребители очаквате да имате?", "prospectiveSitesQuestion": "Колко потенциални сайтове (тунели) очаквате да имате?", "companyName": "Фирмено име", "countryOfResidence": "Държава на пребиваване", "stateProvinceRegion": "Държава / Област / Регион", "postalZipCode": "Пощенски / ЗИП код", "companyWebsite": "Фирмен уебсайт", "companyPhoneNumber": "Фирмен телефонен номер", "country": "Държава", "phoneNumberOptional": "Телефонен номер (по избор)", "complianceConfirmation": "Потвърждавам, че предоставената от мен информация е точна и че съм в съответствие с търговския лиценз Fossorial. Съобщаването на неточна информация или неправилното идентифициране на използването на продукта е нарушение на лиценза и може да доведе до анулиране на вашия ключ." }, "buttons": { "close": "Затвори", "previous": "Предишен", "next": "Следващ", "generateLicenseKey": "Генериране на лицензионен ключ" }, "toasts": { "success": { "title": "Лицензионният ключ е успешно генериран", "description": "Вашият лицензионен ключ е генериран и готов за употреба." }, "error": { "title": "Грешка при генериране на лицензионен ключ", "description": "Възникна грешка при генериране на лицензионния ключ." } } }, "newPricingLicenseForm": { "title": "Получаване на лиценз", "description": "Изберете план и ни кажете как планирате да използвате Pangolin.", "chooseTier": "Изберете вашия план", "viewPricingLink": "Вижте цените, функциите и ограниченията", "tiers": { "starter": { "title": "Стартов", "description": "Предприятие, 25 потребители, 25 сайта и общностна поддръжка." }, "scale": { "title": "Скала", "description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка." } }, "personalUseOnly": "Само за лична употреба (безплатен лиценз — без плащане)", "buttons": { "continueToCheckout": "Продължете към плащане" }, "toasts": { "checkoutError": { "title": "Грешка при плащането", "description": "Не можа да се започне плащането. Моля, опитайте отново." } } }, "priority": "Приоритет", "priorityDescription": "По-високите приоритетни маршрути се оценяват първи. Приоритет = 100 означава автоматично подреждане (системата решава). Използвайте друго число, за да наложите ръчен приоритет.", "instanceName": "Име на инстанция", "pathMatchModalTitle": "Конфигурация на съвпадение по пътека", "pathMatchModalDescription": "Настройте как трябва да бъдат съвпадани входящите заявки въз основа на техния път.", "pathMatchType": "Вид на съвпадението", "pathMatchPrefix": "Префикс", "pathMatchExact": "Точно", "pathMatchRegex": "Регекс", "pathMatchValue": "Стойност на пътя", "clear": "Изчисти", "saveChanges": "Запиши промените", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/път", "pathMatchPrefixHelp": "Пример: /api съвпада /api, /api/потребители и т.н.", "pathMatchExactHelp": "Пример: /api съвпада само с /api", "pathMatchRegexHelp": "Пример: ^/api/.* съвпада с /api/всичко", "pathRewriteModalTitle": "Конфигурация на пренаписване на пътя", "pathRewriteModalDescription": "Преобразуване на съвпаднатия път преди препращане към целта.", "pathRewriteType": "Вид на пренаписването", "pathRewritePrefixOption": "Префикс - Подмяна на префикса", "pathRewriteExactOption": "Точно - Замяна на целия път", "pathRewriteRegexOption": "Регекс - Смяна на модела", "pathRewriteStripPrefixOption": "Премахване на префикса", "pathRewriteValue": "Стойност на пренаписването", "pathRewriteRegexPlaceholder": "/нов/$1", "pathRewriteDefaultPlaceholder": "/нов-пътека", "pathRewritePrefixHelp": "Заменете съвпаднатия префикс с тази стойност", "pathRewriteExactHelp": "Заменете целия път с тази стойност, когато пътят съвпада точно", "pathRewriteRegexHelp": "Използвайте групи за улавяне като $1, $2 за заместване", "pathRewriteStripPrefixHelp": "Оставете празно, за да премахнете префикса или предоставете нов префикс", "pathRewritePrefix": "Префикс", "pathRewriteExact": "Точно", "pathRewriteRegex": "Регекс", "pathRewriteStrip": "Премахване", "pathRewriteStripLabel": "премахване", "sidebarEnableEnterpriseLicense": "Активиране на корпоративен лиценз", "cannotbeUndone": "Това не може да се отмени.", "toConfirm": "за да потвърдите.", "deleteClientQuestion": "Сигурни ли сте, че искате да премахнете клиента от сайта и организацията?", "clientMessageRemove": "След като клиентът бъде премахнат, той вече няма да може да се свързва с сайта.", "sidebarLogs": "Логове", "request": "Изискване", "requests": "Заявки", "logs": "Логове", "logsSettingsDescription": "Мониторинг на събраните от тази организация дневници.", "searchLogs": "Търсете в логовете...", "action": "Действие", "actor": "Извършващ", "timestamp": "Отбелязано време", "accessLogs": "Достъп до логове", "exportCsv": "Експортиране в CSV", "exportError": "Неизвестна грешка при експортиране на CSV.", "exportCsvTooltip": "В рамките на времевия диапазон.", "actorId": "ID на извършващия", "allowedByRule": "Разрешено от правило", "allowedNoAuth": "Разрешено без удостоверение", "validAccessToken": "Валиден токен за достъп", "validHeaderAuth": "Валиден заглавен авто", "validPincode": "Валиден ПИН код", "validPassword": "Валидна парола", "validEmail": "Валиден имейл", "validSSO": "Валидно SSO", "resourceBlocked": "Блокирани ресурси", "droppedByRule": "Прекратено от правило", "noSessions": "Няма сесии", "temporaryRequestToken": "Временен токен на заявка", "noMoreAuthMethods": "Няма валидни методи за удостоверение", "ip": "IP", "reason": "Причина", "requestLogs": "Заявка за логове", "requestAnalytics": "Анализи На Заявки", "host": "Хост", "location": "Местоположение", "actionLogs": "Дневници на действията", "sidebarLogsRequest": "Заявка за логове", "sidebarLogsAccess": "Достъп до логове", "sidebarLogsAction": "Дневници на действията", "logRetention": "Задържане на логове", "logRetentionDescription": "Управлявайте времето за задържане на различни видове логове за тази организация или ги деактивирайте", "requestLogsDescription": "Прегледайте подробни логове на заявки за ресурси в тази организация", "requestAnalyticsDescription": "Вижте подробни анализи на заявки за ресурсите в тази организация", "logRetentionRequestLabel": "Задържане на логове на заявки", "logRetentionRequestDescription": "Колко дълго да се задържат логовете на заявките", "logRetentionAccessLabel": "Задържане на логове за достъп", "logRetentionAccessDescription": "Колко дълго да се задържат логовете за достъп", "logRetentionActionLabel": "Задържане на логове за действия", "logRetentionActionDescription": "Колко дълго да се задържат логовете за действия", "logRetentionDisabled": "Деактивирано", "logRetention3Days": "3 дни", "logRetention7Days": "7 дни", "logRetention14Days": "14 дни", "logRetention30Days": "30 дни", "logRetention90Days": "90 дни", "logRetentionForever": "Завинаги", "logRetentionEndOfFollowingYear": "Край на следващата година", "actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация", "accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация", "licenseRequiredToUse": "Изисква се лиценз за Enterprise Edition, за да използвате тази функция. Тази функция е също достъпна в Pangolin Cloud.", "ossEnterpriseEditionRequired": "Необходимо е изданието Enterprise, за да използвате тази функция. Тази функция е също достъпна в Pangolin Cloud.", "certResolver": "Решавач на сертификати", "certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.", "selectCertResolver": "Изберете решавач на сертификати", "enterCustomResolver": "Въведете персонализиран решавач", "preferWildcardCert": "Предпочитайте универсален сертификат", "unverified": "Невалидиран", "domainSetting": "Настройки на домейните", "domainSettingDescription": "Конфигурирайте настройките за домейна", "preferWildcardCertDescription": "Опитайте да генерирате универсален сертификат (изисква правилно конфигуриран разрешител на сертификати).", "recordName": "Име на запис", "auto": "Автоматично", "TTL": "TTL", "howToAddRecords": "Как да добавите записи", "dnsRecord": "DNS записи", "required": "Задължително", "domainSettingsUpdated": "Настройките на домейна са успешно актуализирани", "orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн", "loadingDNSRecords": "Зареждане на DNS записи...", "olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.", "client": "Клиент", "proxyProtocol": "Настройки на прокси протокол", "proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.", "enableProxyProtocol": "Активирайте прокси протокола", "proxyProtocolInfo": "Запазете IP адресите на клиентите за TCP бекендове", "proxyProtocolVersion": "Версия на прокси протокола", "version1": "Версия 1 (Препоръчително)", "version2": "Версия 2", "versionDescription": "Версия 1 е текстово-базирана и широко поддържана. Версия 2 е бърна и по-ефективна, но по-малко съвместима.", "warning": "Предупреждение", "proxyProtocolWarning": "Вашето бекенд приложение трябва да бъде конфигурирано да приема прокси протоколни връзки. Ако вашият бекенд не поддържа прокси протокол, активирането му ще прекъсне всички връзки. Уверете се, че сте конфигурирали вашия бекенд да се доверява на заглавията на прокси протокола от Traefik.", "restarting": "Рестартиране...", "manual": "Ръководство", "messageSupport": "Съобщение до поддръжката", "supportNotAvailableTitle": "Поддръжката не е налична", "supportNotAvailableDescription": "Поддръжката не е налична в момента. Можете да изпратите имейл на support@pangolin.net.", "supportRequestSentTitle": "Заявката за поддръжка е изпратена", "supportRequestSentDescription": "Вашето съобщение беше изпратено успешно.", "supportRequestFailedTitle": "Неуспешно изпращане на заявка", "supportRequestFailedDescription": "Възникна грешка при изпращането на вашата заявка за поддръжка.", "supportSubjectRequired": "Необходимо е въведеното описание", "supportSubjectMaxLength": "Темата трябва да бъде до 255 символа", "supportMessageRequired": "Необходимо е съобщение", "supportReplyTo": "Отговор до", "supportSubject": "Тема", "supportSubjectPlaceholder": "Въведете тема", "supportMessage": "Съобщение", "supportMessagePlaceholder": "Въведете вашето съобщение", "supportSending": "Изпращане...", "supportSend": "Изпрати", "supportMessageSent": "Съобщението е изпратено!", "supportWillContact": "Ще се свържем с вас скоро!", "selectLogRetention": "Изберете съхранение на логовете", "terms": "Термини", "privacy": "Поверителност", "security": "Сигурност", "docs": "Документи", "deviceActivation": "Активиране на устройство", "deviceCodeInvalidFormat": "Кодът трябва да бъде 9 символа (напр. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Невалиден или изтекъл код", "deviceCodeVerifyFailed": "Неуспешна проверка на кода на устройството", "deviceCodeValidating": "Валидиране на кода на устройството...", "deviceCodeVerifying": "Проверка на оторизацията на устройството...", "signedInAs": "Вписан като", "deviceCodeEnterPrompt": "Въведете кода, показан на устройството", "continue": "Продължете", "deviceUnknownLocation": "Неизвестно местоположение", "deviceAuthorizationRequested": "Това разрешение беше заявено от {location} на {date}. Уверете се, че се доверявате на това устройство, тъй като то ще получи достъп до акаунта.", "deviceLabel": "Устройство: {deviceName}", "deviceWantsAccess": "иска да има достъп до вашия акаунт", "deviceExistingAccess": "Съществуващ достъп:", "deviceFullAccess": "Пълен достъп до вашия акаунт", "deviceOrganizationsAccess": "Достъп до всички организации, до които има достъп акаунтът ви", "deviceAuthorize": "Разрешете {applicationName}", "deviceConnected": "Устройството е свързано!", "deviceAuthorizedMessage": "Устройството е оторизирано да има достъп до акаунта ви. Моля, върнете се към клиентското приложение.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "Преглед на устройствата", "viewDevicesDescription": "Управлявайте свързаните си устройства", "noDevices": "Не са намерени устройства", "dateCreated": "Дата на създаване", "unnamedDevice": "Устройство без име", "deviceQuestionRemove": "Сигурни ли сте, че искате да изтриете това устройство?", "deviceMessageRemove": "Това действие не може да бъде отменено.", "deviceDeleteConfirm": "Изтриване на устройство", "deleteDevice": "Изтриване на устройство", "errorLoadingDevices": "Грешка при зареждане на устройства", "failedToLoadDevices": "Неуспешно зареждане на устройства", "deviceDeleted": "Устройството е изтрито", "deviceDeletedDescription": "Устройството бе успешно изтрито.", "errorDeletingDevice": "Грешка при изтриване на устройството", "failedToDeleteDevice": "Неуспешно изтриване на устройството", "showColumns": "Покажи колони", "hideColumns": "Скрий колони", "columnVisibility": "Видимост на колоните", "toggleColumn": "Превключване на колоната {columnName}", "allColumns": "Всички колони", "defaultColumns": "По подразбиране колони", "customizeView": "Персонализиране на изгледа", "viewOptions": "Опции за изгледа", "selectAll": "Избери всички", "selectNone": "Избери нищо", "selectedResources": "Избрани ресурси", "enableSelected": "Разреши избраните", "disableSelected": "Забрани избраните", "checkSelectedStatus": "Проверете състоянието на избраните", "clients": "Клиенти", "accessClientSelect": "Изберете машинни клиенти", "resourceClientDescription": "Машинни клиенти, които могат да получат достъп до този ресурс", "regenerate": "Генерирай", "credentials": "Удостоверения", "savecredentials": "Запазване на удостоверения", "regenerateCredentialsButton": "Генериране на нови удостоверителни данни", "regenerateCredentials": "Генериране на нови удостоверителни данни", "generatedcredentials": "Прегенерирани удостоверения", "copyandsavethesecredentials": "Копирайте и запазете тези удостоверения", "copyandsavethesecredentialsdescription": "Тези удостоверения няма да бъдат показани отново след като напуснете тази страница. Запазете ги сигурно сега.", "credentialsSaved": "Удостоверенията са запазени", "credentialsSavedDescription": "Удостоверенията бяха прегенерирани и успешно запазени.", "credentialsSaveError": "Грешка при запазването на удостоверенията", "credentialsSaveErrorDescription": "Възникна грешка при прегенерирането и запазването на удостоверенията.", "regenerateCredentialsWarning": "Генерирането на нови удостоверителни данни ще направи предишните невалидни и ще причини прекъсване на връзката. Уверете се, че сте актуализирали всички конфигурации, които използват тези удостоверителни данни.", "confirm": "Потвърждаване", "regenerateCredentialsConfirmation": "Сигурни ли сте, че искате да прегенерирате удостоверенията?", "endpoint": "Крайна точка", "Id": "Идентификатор", "SecretKey": "Таен ключ", "niceId": "Красив ID", "niceIdUpdated": "Красив ID е обновен", "niceIdUpdatedSuccessfully": "Красив ID е успешно обновен", "niceIdUpdateError": "Грешка при обновяването на Красив ID", "niceIdUpdateErrorDescription": "Възникна грешка при обновяването на Красив ID.", "niceIdCannotBeEmpty": "Красив ID не може да бъде празен", "enterIdentifier": "Въведете идентификатор", "identifier": "Идентификатор", "deviceLoginUseDifferentAccount": "Не сте вие? Използвайте друг акаунт.", "deviceLoginDeviceRequestingAccessToAccount": "Устройство запитващо достъп до този акаунт.", "loginSelectAuthenticationMethod": "Изберете метод на удостоверяване, за да продължите.", "noData": "Няма Данни", "machineClients": "Машинни клиенти", "install": "Инсталирай", "run": "Изпълни", "clientNameDescription": "Показваното име на клиента, което може да се промени по-късно.", "clientAddress": "Клиентски адрес (Разширено)", "setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране", "setupSubnetAdvanced": "Подмрежа (Разширено)", "setupSubnetDescription": "Подмрежата за вътрешната мрежа на тази организация.", "setupUtilitySubnet": "Помощен подсегмент (Напреднало).", "setupUtilitySubnetDescription": "Подсегментът за псевдонимите на тази организация и DNS сървъра.", "siteRegenerateAndDisconnect": "Генериране и прекъсване на връзката", "siteRegenerateAndDisconnectConfirmation": "Сигурни ли сте, че искате да генерирате нови удостоверителни данни и да прекъснете тази връзка?", "siteRegenerateAndDisconnectWarning": "Това ще генерира нови удостоверителни данни и незабавно ще прекъсне връзката. На сайта ще трябва да се рестартира с новите удостоверителни данни.", "siteRegenerateCredentialsConfirmation": "Сигурни ли сте, че искате да генерирате новите удостоверителни данни за този сайт?", "siteRegenerateCredentialsWarning": "Това ще генерира нови удостоверителни данни. Сайтът ще остане свързан, докато не го рестартирате ръчно и използвате новите удостоверителни данни.", "clientRegenerateAndDisconnect": "Генериране и прекъсване на връзката", "clientRegenerateAndDisconnectConfirmation": "Сигурни ли сте, че искате да генерирате нови удостоверителни данни и да прекъснете връзката на този клиент?", "clientRegenerateAndDisconnectWarning": "Това ще генерира нови удостоверителни данни и незабавно ще прекъсне връзката на клиента. Клиентът ще трябва да се рестартира с новите удостоверителни данни.", "clientRegenerateCredentialsConfirmation": "Сигурни ли сте, че искате да генерирате новите удостоверителни данни за този клиент?", "clientRegenerateCredentialsWarning": "Това ще генерира нови удостоверителни данни. Клиентът ще остане свързан, докато не го рестартирате ръчно и използвате новите удостоверителни данни.", "remoteExitNodeRegenerateAndDisconnect": "Генериране и прекъсване на връзката", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Сигурни ли сте, че искате да генерирате нови удостоверителни данни и да прекъснете връзката на този отдалечен възел?", "remoteExitNodeRegenerateAndDisconnectWarning": "Това ще генерира нови удостоверителни данни и незабавно ще прекъсне връзката на отдалечения възел. Отдалеченият възел ще трябва да се рестартира с новите удостоверителни данни.", "remoteExitNodeRegenerateCredentialsConfirmation": "Сигурни ли сте, че искате да генерирате новите удостоверителни данни за този отдалечен възел?", "remoteExitNodeRegenerateCredentialsWarning": "Това ще генерира нови удостоверителни данни. Отдалеченият възел ще остане свързан, докато не го рестартирате ръчно и използвате новите удостоверителни данни.", "agent": "Агент", "personalUseOnly": "Само за лична употреба.", "loginPageLicenseWatermark": "Тази инстанция е лицензирана само за лична употреба.", "instanceIsUnlicensed": "Тази инстанция е без лиценз.", "portRestrictions": "Ограничения на портовете.", "allPorts": "Всички.", "custom": "Персонализирано.", "allPortsAllowed": "Всички портове са разрешени.", "allPortsBlocked": "Всички портове са блокирани.", "tcpPortsDescription": "Посочете кои TCP портове са разрешени за този ресурс. Използвайте '*' за всички портове, оставете празно, за да блокирате всички, или въведете списък от отделени с запетая портове и диапазони (например: 80,443, 8000-9000).", "udpPortsDescription": "Посочете кои UDP портове са разрешени за този ресурс. Използвайте '*' за всички портове, оставете празно, за да блокирате всички, или въведете списък от отделени с запетая портове и диапазони (например: 53,123, 500-600).", "organizationLoginPageTitle": "Страница за вход на организацията.", "organizationLoginPageDescription": "Персонализирайте страницата за влизане за тази организация.", "resourceLoginPageTitle": "Страница за вход на ресурса.", "resourceLoginPageDescription": "Персонализирайте страницата за вход за конкретни ресурси.", "enterConfirmation": "Въведете потвърждение.", "blueprintViewDetails": "Подробности.", "defaultIdentityProvider": "По подразбиране доставчик на идентичност.", "defaultIdentityProviderDescription": "Когато е избран основен доставчик на идентичност, потребителят ще бъде автоматично пренасочен към доставчика за удостоверяване.", "editInternalResourceDialogNetworkSettings": "Мрежови настройки.", "editInternalResourceDialogAccessPolicy": "Политика за достъп.", "editInternalResourceDialogAddRoles": "Добавяне на роли.", "editInternalResourceDialogAddUsers": "Добавяне на потребители.", "editInternalResourceDialogAddClients": "Добавяне на клиенти.", "editInternalResourceDialogDestinationLabel": "Дестинация.", "editInternalResourceDialogDestinationDescription": "Посочете адреса дестинация за вътрешния ресурс. Това може да бъде име на хост, IP адрес или CIDR обхват в зависимост от избрания режим. По избор настройте вътрешен DNS алиас за по-лесно идентифициране.", "editInternalResourceDialogPortRestrictionsDescription": "Ограничете достъпа до конкретни TCP/UDP портове или позволете/блокирайте всички портове.", "editInternalResourceDialogTcp": "TCP.", "editInternalResourceDialogUdp": "UDP.", "editInternalResourceDialogIcmp": "ICMP.", "editInternalResourceDialogAccessControl": "Контрол на достъпа.", "editInternalResourceDialogAccessControlDescription": "Контролирайте кои роли, потребители и клиентски машини имат достъп до този ресурс, когато са свързани. Администраторите винаги имат достъп.", "editInternalResourceDialogPortRangeValidationError": "Обхватът на портовете трябва да е \"*\" за всички портове или списък от разделени със запетая портове и диапазони (например: \"80,443,8000-9000\"). Портовете трябва да са между 1 и 65535.", "internalResourceAuthDaemonStrategy": "Локация на SSH Auth Daemon", "internalResourceAuthDaemonStrategyDescription": "Изберете къде ще работи демонът за SSH удостоверение: на сайта (Newt) или на отдалечен хост.", "internalResourceAuthDaemonDescription": "Демонът за SSH удостоверение управлява подписването на SSH ключове и PAM удостоверение за този ресурс. Изберете дали да работи на сайта (Newt) или на отделен отдалечен хост. Вижте документацията за повече информация.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Изберете стратегия", "internalResourceAuthDaemonStrategyLabel": "Местоположение", "internalResourceAuthDaemonSite": "На сайта", "internalResourceAuthDaemonSiteDescription": "Демонът за удостоверение работи на сайта (Newt).", "internalResourceAuthDaemonRemote": "Отдалечен хост", "internalResourceAuthDaemonRemoteDescription": "Демонът за удостоверение работи на хост, който не е сайтът.", "internalResourceAuthDaemonPort": "Порт на демона (незадължителен)", "orgAuthWhatsThis": "Къде мога да намеря идентификатора на организацията си?", "learnMore": "Научете повече.", "backToHome": "Връщане към началната страница.", "needToSignInToOrg": "Трябва ли да използвате доставчика на идентичност на организацията си?", "maintenanceMode": "Режим на поддръжка.", "maintenanceModeDescription": "Показване на страницата за поддръжка на посетители.", "maintenanceModeType": "Тип режим на поддръжка.", "showMaintenancePage": "Показване на страницата за поддръжка на посетители.", "enableMaintenanceMode": "Активиране на режим на поддръжка.", "automatic": "Автоматично.", "automaticModeDescription": "Показване на страницата за поддръжка само когато всички целеви подсистеми са неработоспособни или в лошо състояние. Вашият ресурс продължава да работи нормално, докато поне един целеви подсистемен елемент е в здравия диапазон.", "forced": "Наложително.", "forcedModeDescription": "Винаги показвайте страницата за поддръжка, без значение от състоянието на подсистемите. Използвайте това за планирана поддръжка, когато искате да предотвратите всякакъв достъпен достъп.", "warning:": "Предупреждение:", "forcedeModeWarning": "Целият трафик ще бъде пренасочен към страницата за поддръжка. Вашите подсистемни ресурси няма да получат никакви заявки.", "pageTitle": "Заглавие на страницата.", "pageTitleDescription": "Основното заглавие, показвано на страницата за поддръжка.", "maintenancePageMessage": "Съобщение за поддръжка.", "maintenancePageMessagePlaceholder": "Ще се върнем скоро! Нашият сайт понастоящем е в процес на планирана поддръжка.", "maintenancePageMessageDescription": "Подробно съобщение, обясняващо поддръжката.", "maintenancePageTimeTitle": "Очаквано време за завършване (по избор).", "maintenanceTime": "например, 2 часа, 1 ноември в 17:00.", "maintenanceEstimatedTimeDescription": "Кога очаквате поддръжката да бъде завършена?", "editDomain": "Редактиране на домейна.", "editDomainDescription": "Изберете домейн за вашия ресурс.", "maintenanceModeDisabledTooltip": "Тази функция изисква валиден лиценз за активиране.", "maintenanceScreenTitle": "Услугата временно недостъпна.", "maintenanceScreenMessage": "В момента срещаме технически затруднения. Моля, проверете отново скоро.", "maintenanceScreenEstimatedCompletion": "Прогнозно завършване:", "createInternalResourceDialogDestinationRequired": "Дестинацията е задължителна.", "available": "Налично", "archived": "Архивирано", "noArchivedDevices": "Не са намерени архивирани устройства.", "deviceArchived": "Устройството е архивирано.", "deviceArchivedDescription": "Устройството беше успешно архивирано.", "errorArchivingDevice": "Грешка при архивиране на устройството.", "failedToArchiveDevice": "Неуспех при архивиране на устройството.", "deviceQuestionArchive": "Сигурни ли сте, че искате да архивирате това устройство?", "deviceMessageArchive": "Устройството ще бъде архивирано и премахнато от вашия списък с активни устройства.", "deviceArchiveConfirm": "Архивиране на устройството", "archiveDevice": "Архивиране на устройство", "archive": "Архив", "deviceUnarchived": "Устройството е разархивирано.", "deviceUnarchivedDescription": "Устройството беше успешно разархивирано.", "errorUnarchivingDevice": "Грешка при разархивиране на устройството.", "failedToUnarchiveDevice": "Неуспешно разархивиране на устройството.", "unarchive": "Разархивиране", "archiveClient": "Архивиране на клиента", "archiveClientQuestion": "Сигурни ли сте, че искате да архивирате този клиент?", "archiveClientMessage": "Клиентът ще бъде архивиран и премахнат от вашия списък с активни клиенти.", "archiveClientConfirm": "Архивиране на клиента", "blockClient": "Блокиране на клиента", "blockClientQuestion": "Сигурни ли сте, че искате да блокирате този клиент?", "blockClientMessage": "Устройството ще бъде принудено да прекъсне, ако е в момента свързано. Можете да го отблокирате по-късно.", "blockClientConfirm": "Блокиране на клиента", "active": "Активно", "usernameOrEmail": "Потребителско име или имейл", "selectYourOrganization": "Изберете вашата организация", "signInTo": "Влезте в", "signInWithPassword": "Продължете с парола", "noAuthMethodsAvailable": "Няма налични методи за удостоверяване за тази организация.", "enterPassword": "Въведете вашата парола", "enterMfaCode": "Въведете кода от вашето приложение за удостоверяване", "securityKeyRequired": "Моля, използвайте ключа за сигурност, за да влезете.", "needToUseAnotherAccount": "Трябва ли да използвате различен акаунт?", "loginLegalDisclaimer": "С натискането на бутоните по-долу, потвърждавате, че сте прочели, разбирате и се съгласявате с Условията за ползване и Политиката за поверителност.", "termsOfService": "Условия за ползване", "privacyPolicy": "Политика за поверителност", "userNotFoundWithUsername": "Не е намерен потребител с това потребителско име.", "verify": "Потвърждение", "signIn": "Вход", "forgotPassword": "Забравена парола?", "orgSignInTip": "Ако сте влизали преди, можете да въведете вашето потребителско име или имейл по-горе, за да се удостовери с идентификатора на вашата организация. Лесно е!", "continueAnyway": "Продължете въпреки това", "dontShowAgain": "Не показвайте повече", "orgSignInNotice": "Знаете ли?", "signupOrgNotice": "Опитвате се да влезете?", "signupOrgTip": "Опитвате ли се да влезете чрез идентификационния доставчик на вашата организация?", "signupOrgLink": "Влезте или се регистрирайте с вашата организация вместо това.", "verifyEmailLogInWithDifferentAccount": "Използвайте различен акаунт", "logIn": "Вход", "deviceInformation": "Информация за устройството", "deviceInformationDescription": "Информация за устройството и агента", "deviceSecurity": "Защита на устройството.", "deviceSecurityDescription": "Информация за състоянието на защитата на устройството.", "platform": "Платформа", "macosVersion": "Версия на macOS", "windowsVersion": "Версия на Windows", "iosVersion": "Версия на iOS", "androidVersion": "Версия на Android", "osVersion": "Версия на ОС", "kernelVersion": "Версия на ядрото", "deviceModel": "Модел на устройството", "serialNumber": "Сериен номер", "hostname": "Име на хост", "firstSeen": "Видян за първи път", "lastSeen": "Последно видян", "biometricsEnabled": "Активирани биометрични данни.", "diskEncrypted": "Криптиран диск.", "firewallEnabled": "Активирана защитна стена.", "autoUpdatesEnabled": "Активирани автоматични актуализации.", "tpmAvailable": "TPM е на разположение.", "windowsAntivirusEnabled": "Активирана антивирусна програма", "macosSipEnabled": "Protection на системната цялост (SIP).", "macosGatekeeperEnabled": "Gatekeeper.", "macosFirewallStealthMode": "Скрит режим на защитната стена.", "linuxAppArmorEnabled": "AppArmor.", "linuxSELinuxEnabled": "SELinux.", "deviceSettingsDescription": "Разгледайте информация и настройки на устройството", "devicePendingApprovalDescription": "Това устройство чака одобрение", "deviceBlockedDescription": "Това устройство е в момента блокирано. Няма да може да се свърже с никакви ресурси, освен ако не бъде деблокирано.", "unblockClient": "Деблокирайте клиента", "unblockClientDescription": "Устройството е деблокирано", "unarchiveClient": "Разархивиране на клиента", "unarchiveClientDescription": "Устройството е разархивирано", "block": "Блокирането", "unblock": "Деблокиране", "deviceActions": "Действия с устройствата", "deviceActionsDescription": "Управлявайте състоянието и достъпа на устройството", "devicePendingApprovalBannerDescription": "Това устройство чака одобрение. Няма да може да се свърже с ресурси, докато не бъде одобрено.", "connected": "Свързан", "disconnected": "Прекъснат", "approvalsEmptyStateTitle": "Одобрения на устройство не са активирани", "approvalsEmptyStateDescription": "Активирайте одобрения на устройства за роли, така че да изискват администраторско одобрение, преди потребителите да могат да свързват нови устройства.", "approvalsEmptyStateStep1Title": "Отидете на роли", "approvalsEmptyStateStep1Description": "Навигирайте до настройките на ролите на вашата организация, за да конфигурирате одобренията на устройства.", "approvalsEmptyStateStep2Title": "Активирайте одобрения на устройства", "approvalsEmptyStateStep2Description": "Редактирайте ролята и активирайте опцията 'Изискване на одобрения за устройства'. Потребители с тази роля ще трябва администраторско одобрение за нови устройства.", "approvalsEmptyStatePreviewDescription": "Преглед: Когато е активирано, чакащите заявки за устройства ще се появят тук за преглед", "approvalsEmptyStateButtonText": "Управлявайте роли" } ================================================ FILE: messages/cs-CZ.json ================================================ { "setupCreate": "Vytvořte organizaci, stránku a zdroje", "headerAuthCompatibilityInfo": "Povolte toto, aby vyvolalo odpověď 401 Neoprávněné, když chybí autentizační token. Toto je potřeba pro prohlížeče nebo specifické HTTP knihovny, které neposílají přihlašovací údaje bez výzvy serveru.", "headerAuthCompatibility": "Rozšířená kompatibilita", "setupNewOrg": "Nová organizace", "setupCreateOrg": "Vytvořit organizaci", "setupCreateResources": "Vytvořit zdroje", "setupOrgName": "Název organizace", "orgDisplayName": "Toto je zobrazený název organizace.", "orgId": "ID organizace", "setupIdentifierMessage": "Toto je jedinečný identifikátor organizace.", "setupErrorIdentifier": "ID organizace je již použito. Zvolte prosím jiné.", "componentsErrorNoMemberCreate": "Zatím nejste členem žádné organizace. Abyste mohli začít, vytvořte si organizaci.", "componentsErrorNoMember": "Zatím nejste členem žádných organizací.", "welcome": "Vítejte!", "welcomeTo": "Vítejte v", "componentsCreateOrg": "Vytvořte organizaci", "componentsMember": "Jste členem {count, plural, =0 {0 organizací} one {1 organizace} other {# organizací}}.", "componentsInvalidKey": "Byly nalezeny neplatné nebo propadlé licenční klíče. Pokud chcete nadále používat všechny funkce, postupujte podle licenčních podmínek.", "dismiss": "Zavřít", "subscriptionViolationMessage": "Jste za hranicemi vašeho aktuálního plánu. Opravte problém odstraněním webů, uživatelů nebo jiných zdrojů, abyste zůstali ve vašem tarifu.", "subscriptionViolationViewBilling": "Zobrazit fakturaci", "componentsLicenseViolation": "Porušení licenčních podmínek: Tento server používá {usedSites} stránek, což překračuje limit {maxSites} licencovaných stránek. Pokud chcete nadále používat všechny funkce, postupujte podle licenčních podmínek.", "componentsSupporterMessage": "Děkujeme, že podporujete Pangolin jako {tier}!", "inviteErrorNotValid": "Je nám líto, ale vypadá to, že pozvánka, ke které se snažíte získat přístup, nebyla přijata nebo již není platná.", "inviteErrorUser": "Je nám líto, ale vypadá to, že pozvánka, ke které se snažíte získat přístup, není pro tohoto uživatele.", "inviteLoginUser": "Prosím ujistěte se, že jste přihlášeni jako správný uživatel.", "inviteErrorNoUser": "Je nám líto, ale vypadá to, že pozvánka, ke které se snažíte získat přístup, není pro existujícího uživatele.", "inviteCreateUser": "Nejprve si prosím vytvořte účet.", "goHome": "Přejít na hlavní stránku", "inviteLogInOtherUser": "Přihlásit se jako jiný uživatel", "createAnAccount": "Vytvořit účet", "inviteNotAccepted": "Pozvánka nebyla přijata", "authCreateAccount": "Vytvořte si účet, abyste mohli začít", "authNoAccount": "Nemáte účet?", "email": "Email", "password": "Heslo", "confirmPassword": "Potvrďte heslo", "createAccount": "Vytvořit účet", "viewSettings": "Zobrazit nastavení", "delete": "Odstranit", "name": "Jméno", "online": "Online", "offline": "Offline", "site": "Lokalita", "dataIn": "Přijatá data", "dataOut": "Odeslaná data", "connectionType": "Typ připojení", "tunnelType": "Typ tunelu", "local": "Místní", "edit": "Upravit", "siteConfirmDelete": "Potvrdit odstranění lokality", "siteDelete": "Odstranění lokality", "siteMessageRemove": "Po odstranění webu již nebude přístupný. Všechny cíle spojené s webem budou také odstraněny.", "siteQuestionRemove": "Jste si jisti, že chcete odstranit tuto stránku z organizace?", "siteManageSites": "Správa lokalit", "siteDescription": "Vytvořte a spravujte stránky pro povolení připojení k soukromým sítím", "sitesBannerTitle": "Připojit jakoukoli síť", "sitesBannerDescription": "Lokalita je připojení k vzdálené síti, která umožňuje Pangolinu poskytovat přístup k prostředkům, ať už veřejným nebo soukromým, uživatelům kdekoli. Nainstalujte síťový konektor (Newt) kamkoli, kam můžete spustit binární soubor nebo kontejner, aby bylo možné připojení navázat.", "sitesBannerButtonText": "Nainstalovat lokalitu", "approvalsBannerTitle": "Schválit nebo zakázat přístup k zařízení", "approvalsBannerDescription": "Zkontrolovat a schválit nebo zakázat žádosti uživatelů o přístup k zařízení. Pokud jsou vyžadována schválení zařízení, musí být uživatelé oprávněni před tím, než se jejich zařízení mohou připojit k zdrojům vaší organizace.", "approvalsBannerButtonText": "Zjistit více", "siteCreate": "Vytvořit lokalitu", "siteCreateDescription2": "Postupujte podle níže uvedených kroků, abyste vytvořili a připojili novou lokalitu", "siteCreateDescription": "Vytvořit nový web pro zahájení připojování zdrojů", "close": "Zavřít", "siteErrorCreate": "Chyba při vytváření lokality", "siteErrorCreateKeyPair": "Nebyly nalezeny klíče nebo výchozí nastavení lokality", "siteErrorCreateDefaults": "Výchozí nastavení lokality nenalezeno", "method": "Způsob", "siteMethodDescription": "Tímto způsobem budete vystavovat spojení.", "siteLearnNewt": "Naučte se, jak nainstalovat Newt na svůj systém", "siteSeeConfigOnce": "Konfiguraci uvidíte pouze jednou.", "siteLoadWGConfig": "Načítání konfigurace WireGuard...", "siteDocker": "Rozbalit pro detaily nasazení v Dockeru", "toggle": "Přepínač", "dockerCompose": "Docker Compose", "dockerRun": "Docker Run", "siteLearnLocal": "Místní lokality se netunelují, dozvědět se více", "siteConfirmCopy": "Konfiguraci jsem zkopíroval", "searchSitesProgress": "Hledat lokality...", "siteAdd": "Přidat lokalitu", "siteInstallNewt": "Nainstalovat Newt", "siteInstallNewtDescription": "Spustit Newt na vašem systému", "WgConfiguration": "Konfigurace WireGuard", "WgConfigurationDescription": "K připojení k síti použijte následující konfiguraci", "operatingSystem": "Operační systém", "commands": "Příkazy", "recommended": "Doporučeno", "siteNewtDescription": "Ideálně použijte Newt, který využívá WireGuard a umožňuje adresovat vaše soukromé zdroje pomocí jejich LAN adresy ve vaší privátní síti přímo z dashboardu Pangolin.", "siteRunsInDocker": "Běží v Dockeru", "siteRunsInShell": "Běží v shellu na macOS, Linuxu a Windows", "siteErrorDelete": "Chyba při odstraňování lokality", "siteErrorUpdate": "Nepodařilo se upravit lokalitu", "siteErrorUpdateDescription": "Při úpravě lokality došlo k chybě.", "siteUpdated": "Lokalita upravena", "siteUpdatedDescription": "Lokalita byla upravena.", "siteGeneralDescription": "Upravte obecná nastavení pro tuto lokalitu", "siteSettingDescription": "Konfigurace nastavení na webu", "siteSetting": "Nastavení {siteName}", "siteNewtTunnel": "Novinka (doporučeno)", "siteNewtTunnelDescription": "Nejjednodušší způsob, jak vytvořit vstupní bod do jakékoli sítě. Žádné další nastavení.", "siteWg": "Základní WireGuard", "siteWgDescription": "Použijte jakéhokoli klienta WireGuard abyste sestavili tunel. Vyžaduje se ruční nastavení NAT.", "siteWgDescriptionSaas": "Použijte jakéhokoli klienta WireGuard abyste sestavili tunel. Vyžaduje se ruční nastavení NAT. FUNGUJE POUZE NA SELF-HOSTED SERVERECH", "siteLocalDescription": "Pouze lokální zdroje. Žádný tunel.", "siteLocalDescriptionSaas": "Pouze místní zdroje. Žádný tunel. Dostupné pouze na vzdálených uzlech.", "siteSeeAll": "Zobrazit všechny lokality", "siteTunnelDescription": "Určete, jak se chcete připojit k webu", "siteNewtCredentials": "Pověření", "siteNewtCredentialsDescription": "Takto se bude stránka autentizovat se serverem", "remoteNodeCredentialsDescription": "Takto se vzdálený uzel autentizuje s serverem", "siteCredentialsSave": "Uložit pověření", "siteCredentialsSaveDescription": "Toto nastavení uvidíte pouze jednou. Ujistěte se, že jej zkopírujete na bezpečné místo.", "siteInfo": "Údaje o lokalitě", "status": "Stav", "shareTitle": "Spravovat sdílení odkazů", "shareDescription": "Vytvořit sdílitelné odkazy pro udělení dočasného nebo trvalého přístupu ke zdrojům proxy", "shareSearch": "Hledat sdílené odkazy...", "shareCreate": "Vytvořit odkaz", "shareErrorDelete": "Nepodařilo se odstranit odkaz", "shareErrorDeleteMessage": "Došlo k chybě při odstraňování odkazu", "shareDeleted": "Odkaz odstraněn", "shareDeletedDescription": "Odkaz byl odstraněn", "shareTokenDescription": "Přístupový token může být předán dvěma způsoby: jako parametr dotazu nebo v záhlaví požadavku. Tyto údaje musí být předány klientovi na každé žádosti o ověřený přístup.", "accessToken": "Přístupový token", "usageExamples": "Příklady použití", "tokenId": "ID tokenu", "requestHeades": "Hlavičky požadavku", "queryParameter": "Parametry dotazu", "importantNote": "Důležité upozornění", "shareImportantDescription": "Z bezpečnostních důvodů je doporučeno používat raději hlavičky než parametry dotazu pokud je to možné, protože parametry dotazu mohou být zaznamenány v logu serveru nebo v historii prohlížeče.", "token": "Token", "shareTokenSecurety": "Udržujte přístupový token v bezpečí. Nesdílejte jej ve veřejně přístupných oblastech nebo kódu na straně klienta.", "shareErrorFetchResource": "Nepodařilo se načíst zdroje", "shareErrorFetchResourceDescription": "Při načítání zdrojů došlo k chybě", "shareErrorCreate": "Nepodařilo se vytvořit odkaz", "shareErrorCreateDescription": "Při vytváření odkazu došlo k chybě", "shareCreateDescription": "Kdokoliv s tímto odkazem může přistupovat ke zdroji", "shareTitleOptional": "Název (volitelné)", "expireIn": "Platnost vyprší za", "neverExpire": "Nikdy nevyprší", "shareExpireDescription": "Doba platnosti určuje, jak dlouho bude odkaz použitelný a bude poskytovat přístup ke zdroji. Po této době odkaz již nebude fungovat a uživatelé kteří tento odkaz používali ztratí přístup ke zdroji.", "shareSeeOnce": "Tento odkaz uvidíte pouze jednou. Nezapomeňte jej zkopírovat.", "shareAccessHint": "Kdokoli s tímto odkazem může přistupovat ke zdroji. Sdílejte jej s rozvahou.", "shareTokenUsage": "Zobrazit využití přístupového tokenu", "createLink": "Vytvořit odkaz", "resourcesNotFound": "Nebyly nalezeny žádné zdroje", "resourceSearch": "Vyhledat zdroje", "openMenu": "Otevřít nabídku", "resource": "Zdroj", "title": "Název", "created": "Vytvořeno", "expires": "Vyprší", "never": "Nikdy", "shareErrorSelectResource": "Zvolte prosím zdroj", "proxyResourceTitle": "Spravovat veřejné zdroje", "proxyResourceDescription": "Vytváření a správa zdrojů, které jsou veřejně přístupné prostřednictvím webového prohlížeče", "proxyResourcesBannerTitle": "Veřejný přístup založený na webu", "proxyResourcesBannerDescription": "Veřejné prostředky jsou HTTPS nebo TCP/UDP proxy, které jsou přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.", "clientResourceTitle": "Spravovat soukromé zdroje", "clientResourceDescription": "Vytváření a správa zdrojů, které jsou přístupné pouze prostřednictvím připojeného klienta", "privateResourcesBannerTitle": "Zero-Trust soukromý přístup", "privateResourcesBannerDescription": "Soukromé prostředky používají zero-trust zabezpečení, což zajišťuje, že uživatelé a zařízení mohou získat přístup pouze k prostředkům, k nimž máte explicitní práva. Připojte zařízení uživatele nebo klientské stroje, abyste získali přístup k těmto prostředkům přes zabezpečenou virtuální soukromou síť.", "resourcesSearch": "Prohledat zdroje...", "resourceAdd": "Přidat zdroj", "resourceErrorDelte": "Chyba při odstraňování zdroje", "authentication": "Autentifikace", "protected": "Chráněno", "notProtected": "Nechráněno", "resourceMessageRemove": "Jakmile zdroj odstraníte, nebude dostupný. Všechny související služby a cíle budou také odstraněny.", "resourceQuestionRemove": "Jste si jisti, že chcete odstranit zdroj z organizace?", "resourceHTTP": "Zdroj HTTPS", "resourceHTTPDescription": "Proxy požadavky přes HTTPS pomocí plně kvalifikovaného názvu domény.", "resourceRaw": "Surový TCP/UDP zdroj", "resourceRawDescription": "Proxy požadavky přes nezpracovaný TCP/UDP pomocí čísla portu.", "resourceRawDescriptionCloud": "Požadavky na proxy přes syrové TCP/UDP pomocí portového čísla. ŽÁDOSTI POUŽÍVAT POUŽITÍ Z REMOTE NODE.", "resourceCreate": "Vytvořit zdroj", "resourceCreateDescription": "Postupujte podle níže uvedených kroků, abyste vytvořili a připojili nový zdroj", "resourceSeeAll": "Zobrazit všechny zdroje", "resourceInfo": "Informace o zdroji", "resourceNameDescription": "Toto je zobrazovaný název zdroje.", "siteSelect": "Vybrat lokalitu", "siteSearch": "Hledat lokalitu", "siteNotFound": "Nebyla nalezena žádná lokalita.", "selectCountry": "Vyberte zemi", "searchCountries": "Hledat země...", "noCountryFound": "Nebyla nalezena žádná země.", "siteSelectionDescription": "Tato lokalita poskytne připojení k cíli.", "resourceType": "Typ zdroje", "resourceTypeDescription": "Určete, jak přistupovat ke zdroji", "resourceHTTPSSettings": "Nastavení HTTPS", "resourceHTTPSSettingsDescription": "Nakonfigurujte, jak bude dokument přístupný přes HTTPS", "domainType": "Typ domény", "subdomain": "Subdoména", "baseDomain": "Základní doména", "subdomnainDescription": "Subdoména, kde bude zdroj přístupný.", "resourceRawSettings": "Nastavení TCP/UDP", "resourceRawSettingsDescription": "Nakonfigurujte, jak bude dokument přístupný přes TCP/UDP", "protocol": "Protokol", "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", "resourceAddEntrypoints": "Traefik: Přidat vstupní body", "resourceExposePorts": "Gerbil: Expose Ports in Docker Compose", "resourceLearnRaw": "Naučte se konfigurovat zdroje TCP/UDP", "resourceBack": "Zpět na zdroje", "resourceGoTo": "Přejít na dokument", "resourceDelete": "Odstranit dokument", "resourceDeleteConfirm": "Potvrdit odstranění dokumentu", "visibility": "Viditelnost", "enabled": "Povoleno", "disabled": "Zakázáno", "general": "Obecná ustanovení", "generalSettings": "Obecná nastavení", "proxy": "Proxy server", "internal": "Interní", "rules": "Pravidla", "resourceSettingDescription": "Konfigurace nastavení na zdroji", "resourceSetting": "Nastavení {resourceName}", "alwaysAllow": "Obejít Auth", "alwaysDeny": "Blokovat přístup", "passToAuth": "Předat k ověření", "orgSettingsDescription": "Konfigurace nastavení organizace", "orgGeneralSettings": "Nastavení organizace", "orgGeneralSettingsDescription": "Spravovat podrobnosti a konfiguraci organizace", "saveGeneralSettings": "Uložit obecné nastavení", "saveSettings": "Uložit nastavení", "orgDangerZone": "Nebezpečná zóna", "orgDangerZoneDescription": "Jakmile smažete tento org, nic se nevrátí. Buďte si jistí.", "orgDelete": "Odstranit organizaci", "orgDeleteConfirm": "Potvrdit odstranění organizace", "orgMessageRemove": "Tato akce je nevratná a odstraní všechna související data.", "orgMessageConfirm": "Pro potvrzení zadejte níže uvedený název organizace.", "orgQuestionRemove": "Jste si jisti, že chcete odstranit organizaci?", "orgUpdated": "Organizace byla aktualizována", "orgUpdatedDescription": "Organizace byla aktualizována.", "orgErrorUpdate": "Aktualizace organizace se nezdařila", "orgErrorUpdateMessage": "Došlo k chybě při aktualizaci organizace.", "orgErrorFetch": "Nepodařilo se načíst organizace", "orgErrorFetchMessage": "Došlo k chybě při výpisu vašich organizací", "orgErrorDelete": "Nepodařilo se odstranit organizaci", "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", "accessUsersDescription": "Pozvat a spravovat uživatele s přístupem k této organizaci", "accessUsersSearch": "Hledat uživatele...", "accessUserCreate": "Vytvořit uživatele", "accessUserRemove": "Odstranit uživatele", "username": "Uživatelské jméno", "identityProvider": "Poskytovatel identity", "role": "Role", "nameRequired": "Název je povinný", "accessRolesManage": "Spravovat role", "accessRolesDescription": "Vytvořit a spravovat role pro uživatele v organizaci", "accessRolesSearch": "Hledat role...", "accessRolesAdd": "Přidat roli", "accessRoleDelete": "Odstranit roli", "accessApprovalsManage": "Spravovat schválení", "accessApprovalsDescription": "Zobrazit a spravovat čekající oprávnění pro přístup k této organizaci", "description": "L 343, 22.12.2009, s. 1).", "inviteTitle": "Otevřít pozvánky", "inviteDescription": "Spravovat pozvánky pro ostatní uživatele do organizace", "inviteSearch": "Hledat pozvánky...", "minutes": "Zápis z jednání", "hours": "Hodiny", "days": "Dny", "weeks": "Týdny", "months": "Měsíce", "years": "Roky", "day": "{count, plural, one {# den} other {# dní}}", "apiKeysTitle": "Informace API klíče", "apiKeysConfirmCopy2": "Musíte potvrdit, že jste zkopírovali API klíč.", "apiKeysErrorCreate": "Chyba při vytváření API klíče", "apiKeysErrorSetPermission": "Chyba nastavení oprávnění", "apiKeysCreate": "Generovat API klíč", "apiKeysCreateDescription": "Vygenerovat nový API klíč pro organizaci", "apiKeysGeneralSettings": "Práva", "apiKeysGeneralSettingsDescription": "Určete, co může tento API klíč udělat", "apiKeysList": "Nový API klíč", "apiKeysSave": "Uložit API klíč", "apiKeysSaveDescription": "Toto nastavení uvidíte pouze jednou. Ujistěte se, že jej zkopírujete na bezpečné místo.", "apiKeysInfo": "API klíč je:", "apiKeysConfirmCopy": "Kopíroval jsem API klíč", "generate": "Generovat", "done": "Hotovo", "apiKeysSeeAll": "Zobrazit všechny API klíče", "apiKeysPermissionsErrorLoadingActions": "Chyba při načítání akcí API klíče", "apiKeysPermissionsErrorUpdate": "Chyba nastavení oprávnění", "apiKeysPermissionsUpdated": "Oprávnění byla aktualizována", "apiKeysPermissionsUpdatedDescription": "Oprávnění byla aktualizována.", "apiKeysPermissionsGeneralSettings": "Práva", "apiKeysPermissionsGeneralSettingsDescription": "Určete, co může tento API klíč udělat", "apiKeysPermissionsSave": "Uložit oprávnění", "apiKeysPermissionsTitle": "Práva", "apiKeys": "API klíče", "searchApiKeys": "Hledat API klíče...", "apiKeysAdd": "Generovat API klíč", "apiKeysErrorDelete": "Chyba při odstraňování API klíče", "apiKeysErrorDeleteMessage": "Chyba při odstraňování API klíče", "apiKeysQuestionRemove": "Jste si jisti, že chcete odstranit klíč API z organizace?", "apiKeysMessageRemove": "Po odstranění klíče API již nebude možné použít.", "apiKeysDeleteConfirm": "Potvrdit odstranění API klíče", "apiKeysDelete": "Odstranit klíč API", "apiKeysManage": "Správa API klíčů", "apiKeysDescription": "API klíče se používají k ověření s integračním API", "apiKeysSettings": "Nastavení {apiKeyName}", "userTitle": "Spravovat všechny uživatele", "userDescription": "Zobrazit a spravovat všechny uživatele v systému", "userAbount": "O správě uživatelů", "userAbountDescription": "Tato tabulka zobrazuje všechny root uživatelské objekty v systému. Každý uživatel může patřit do více organizací. Odstranění uživatele z organizace neodstraní jeho kořenový uživatelský objekt - zůstanou v systému. Pro úplné odstranění uživatele ze systému musíte odstranit jejich kořenový uživatelský objekt pomocí smazané akce v této tabulce.", "userServer": "Uživatelé serveru", "userSearch": "Hledat uživatele serveru...", "userErrorDelete": "Chyba při odstraňování uživatele", "userDeleteConfirm": "Potvrdit odstranění uživatele", "userDeleteServer": "Odstranit uživatele ze serveru", "userMessageRemove": "Uživatel bude odstraněn ze všech organizací a bude zcela odstraněn ze serveru.", "userQuestionRemove": "Jste si jisti, že chcete trvale odstranit uživatele ze serveru?", "licenseKey": "Licenční klíč", "valid": "Valid", "numberOfSites": "Počet lokalit", "licenseKeySearch": "Hledat licenční klíče...", "licenseKeyAdd": "Přidat licenční klíč", "type": "Typ", "licenseKeyRequired": "Je vyžadován licenční klíč", "licenseTermsAgree": "Musíte souhlasit s podmínkami licence", "licenseErrorKeyLoad": "Nepodařilo se načíst licenční klíče", "licenseErrorKeyLoadDescription": "Došlo k chybě při načítání licenčních klíčů.", "licenseErrorKeyDelete": "Nepodařilo se odstranit licenční klíč", "licenseErrorKeyDeleteDescription": "Došlo k chybě při odstraňování licenčního klíče.", "licenseKeyDeleted": "Licenční klíč byl smazán", "licenseKeyDeletedDescription": "Licenční klíč byl odstraněn.", "licenseErrorKeyActivate": "Nepodařilo se aktivovat licenční klíč", "licenseErrorKeyActivateDescription": "Došlo k chybě při aktivaci licenčního klíče.", "licenseAbout": "O licencích", "communityEdition": "Komunitní edice", "licenseAboutDescription": "To je pro obchodní a podnikové uživatele, kteří používají Pangolin v komerčním prostředí. Pokud používáte Pangolin pro osobní použití, můžete tuto sekci ignorovat.", "licenseKeyActivated": "Licenční klíč aktivován", "licenseKeyActivatedDescription": "Licenční klíč byl úspěšně aktivován.", "licenseErrorKeyRecheck": "Nepodařilo se znovu zkontrolovat licenční klíče", "licenseErrorKeyRecheckDescription": "Došlo k chybě při opětovné kontrole licenčních klíčů.", "licenseErrorKeyRechecked": "Licenční klíče překontrolovány", "licenseErrorKeyRecheckedDescription": "Všechny licenční klíče byly znovu zkontrolovány", "licenseActivateKey": "Aktivovat licenční klíč", "licenseActivateKeyDescription": "Zadejte licenční klíč pro jeho aktivaci.", "licenseActivate": "Aktivovat licenci", "licenseAgreement": "Zaškrtnutím tohoto políčka potvrdíte, že jste si přečetli licenční podmínky odpovídající úrovni přiřazené k vašemu licenčnímu klíči a souhlasíte s nimi.", "fossorialLicense": "Zobrazit Fossorial Commercial License & Subscription terms", "licenseMessageRemove": "Tímto odstraníte licenční klíč a všechna s ním spojená oprávnění, která mu byla udělena.", "licenseMessageConfirm": "Pro potvrzení zadejte licenční klíč níže.", "licenseQuestionRemove": "Jste si jisti, že chcete odstranit licenční klíč?", "licenseKeyDelete": "Odstranit licenční klíč", "licenseKeyDeleteConfirm": "Potvrdit odstranění licenčního klíče", "licenseTitle": "Správa stavu licence", "licenseTitleDescription": "Zobrazit a spravovat licenční klíče v systému", "licenseHost": "Licence hostitele", "licenseHostDescription": "Správa hlavního licenčního klíče pro hostitele.", "licensedNot": "Bez licence", "hostId": "ID hostitele", "licenseReckeckAll": "Znovu zobrazit všechny klíče", "licenseSiteUsage": "Využití stránek", "licenseSiteUsageDecsription": "Zobrazit počet stránek používajících tuto licenci.", "licenseNoSiteLimit": "Neexistuje žádný limit počtu webů používajících nelicencovaný hostitele.", "licensePurchase": "Zakoupit licenci", "licensePurchaseSites": "Zakoupit další stránky", "licenseSitesUsedMax": "{usedSites} použitých stránek {maxSites}", "licenseSitesUsed": "{count, plural, =0 {# stránek} one {# stránky} other {# stránek}}", "licensePurchaseDescription": "Vyberte kolik stránek chcete {selectedMode, select, license {Zakupte si licenci. Vždy můžete přidat více webů později.} other {Přidejte k vaší existující licenci.}}", "licenseFee": "Licenční poplatek", "licensePriceSite": "Cena za stránku", "total": "Celkem", "licenseContinuePayment": "Pokračovat v platbě", "pricingPage": "cenová stránka", "pricingPortal": "Zobrazit nákupní portál", "licensePricingPage": "Pro nejaktuálnější ceny a slevy navštivte ", "invite": "Pozvánky", "inviteRegenerate": "Obnovit pozvánku", "inviteRegenerateDescription": "Zrušit předchozí pozvání a vytvořit nové", "inviteRemove": "Odstranit pozvánku", "inviteRemoveError": "Nepodařilo se odstranit pozvánku", "inviteRemoveErrorDescription": "Došlo k chybě při odstraňování pozvánky.", "inviteRemoved": "Pozvánka odstraněna", "inviteRemovedDescription": "Pozvánka pro {email} byla odstraněna.", "inviteQuestionRemove": "Jste si jisti, že chcete odstranit pozvánku?", "inviteMessageRemove": "Po odstranění, tato pozvánka již nebude platná. Později můžete uživatele znovu pozvat.", "inviteMessageConfirm": "Pro potvrzení zadejte prosím níže uvedenou e-mailovou adresu.", "inviteQuestionRegenerate": "Jste si jisti, že chcete obnovit pozvánku pro {email}? Tato akce zruší předchozí pozvánku.", "inviteRemoveConfirm": "Potvrdit odstranění pozvánky", "inviteRegenerated": "Pozvánka obnovena", "inviteSent": "Nová pozvánka byla odeslána na {email}.", "inviteSentEmail": "Poslat uživateli oznámení e-mailem", "inviteGenerate": "Nová pozvánka byla vygenerována pro {email}.", "inviteDuplicateError": "Duplicate Invite", "inviteDuplicateErrorDescription": "Pozvánka pro tohoto uživatele již existuje.", "inviteRateLimitError": "Limit sazby překročen", "inviteRateLimitErrorDescription": "Překročil jsi limit 3 regenerací za hodinu. Opakujte akci později.", "inviteRegenerateError": "Nepodařilo se obnovit pozvánku", "inviteRegenerateErrorDescription": "Došlo k chybě při obnovování pozvánky.", "inviteValidityPeriod": "Doba platnosti", "inviteValidityPeriodSelect": "Vyberte dobu platnosti", "inviteRegenerateMessage": "Pozvánka byla obnovena. Uživatel musí mít přístup k níže uvedenému odkazu, aby mohl pozvánku přijmout.", "inviteRegenerateButton": "Regenerovat", "expiresAt": "Vyprší v", "accessRoleUnknown": "Neznámá role", "placeholder": "Zástupný symbol", "userErrorOrgRemove": "Odstranění uživatele se nezdařilo", "userErrorOrgRemoveDescription": "Došlo k chybě při odebírání uživatele.", "userOrgRemoved": "Uživatel odstraněn", "userOrgRemovedDescription": "Uživatel {email} byl odebrán z organizace.", "userQuestionOrgRemove": "Jste si jisti, že chcete odstranit tohoto uživatele z organizace?", "userMessageOrgRemove": "Po odstranění tohoto uživatele již nebude mít přístup k organizaci. Vždy je můžete znovu pozvat později, ale budou muset pozvání znovu přijmout.", "userRemoveOrgConfirm": "Potvrdit odebrání uživatele", "userRemoveOrg": "Odebrat uživatele z organizace", "users": "Uživatelé", "accessRoleMember": "Člen", "accessRoleOwner": "Vlastník", "userConfirmed": "Potvrzeno", "idpNameInternal": "Interní", "emailInvalid": "Neplatná e-mailová adresa", "inviteValidityDuration": "Zvolte prosím dobu trvání", "accessRoleSelectPlease": "Vyberte prosím roli", "usernameRequired": "Uživatelské jméno je povinné", "idpSelectPlease": "Vyberte poskytovatele identity", "idpGenericOidc": "Generic OAuth2/OIDC provider.", "accessRoleErrorFetch": "Nepodařilo se načíst role", "accessRoleErrorFetchDescription": "Při načítání rolí došlo k chybě", "idpErrorFetch": "Nepodařilo se načíst poskytovatele identity", "idpErrorFetchDescription": "Při načítání poskytovatelů identity došlo k chybě", "userErrorExists": "Uživatel již existuje", "userErrorExistsDescription": "Tento uživatel je již členem organizace.", "inviteError": "Nepodařilo se pozvat uživatele", "inviteErrorDescription": "Při pozvání uživatele došlo k chybě", "userInvited": "Uživatel pozván", "userInvitedDescription": "Uživatel byl úspěšně pozván.", "userErrorCreate": "Nepodařilo se vytvořit uživatele", "userErrorCreateDescription": "Došlo k chybě při vytváření uživatele", "userCreated": "Uživatel byl vytvořen", "userCreatedDescription": "Uživatel byl úspěšně vytvořen.", "userTypeInternal": "Interní uživatel", "userTypeInternalDescription": "Pozvěte uživatele do organizace přímo.", "userTypeExternal": "Externí uživatel", "userTypeExternalDescription": "Vytvořte uživatele s externím poskytovatelem identity.", "accessUserCreateDescription": "Postupujte podle níže uvedených kroků pro vytvoření nového uživatele", "userSeeAll": "Zobrazit všechny uživatele", "userTypeTitle": "Typ uživatele", "userTypeDescription": "Určete, jak chcete vytvořit uživatele", "userSettings": "Informace o uživateli", "userSettingsDescription": "Zadejte podrobnosti pro nového uživatele", "inviteEmailSent": "Poslat uživateli pozvánku", "inviteValid": "Platné pro", "selectDuration": "Vyberte dobu trvání", "selectResource": "Vybrat dokument", "filterByResource": "Filtrovat podle zdroje", "selectApprovalState": "Vyberte stát schválení", "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", "deniedApproval": "Odmítnuto schválení", "all": "Vše", "deny": "Zamítnout", "viewDetails": "Zobrazit detaily", "requestingNewDeviceApproval": "vyžádal si nové zařízení", "resetFilters": "Resetovat filtry", "totalBlocked": "Požadavky blokovány Pangolinem", "totalRequests": "Celkem požadavků", "requestsByCountry": "Žádosti podle země", "requestsByDay": "Žádosti podle dne", "blocked": "Blokované", "allowed": "Povoleno", "topCountries": "Nejlepší země", "accessRoleSelect": "Vybrat roli", "inviteEmailSentDescription": "Uživateli byl odeslán e-mail s odkazem pro přístup níže. Pro přijetí pozvánky musí mít přístup k odkazu.", "inviteSentDescription": "Uživatel byl pozván. Pro přijetí pozvánky musí mít přístup na níže uvedený odkaz.", "inviteExpiresIn": "Pozvánka vyprší za {days, plural, one {# den} other {# days}}.", "idpTitle": "Poskytovatel identity", "idpSelect": "Vyberte poskytovatele identity pro externího uživatele", "idpNotConfigured": "Nejsou nakonfigurováni žádní poskytovatelé identity. Před vytvořením externích uživatelů prosím nakonfigurujte poskytovatele identity.", "usernameUniq": "Toto musí odpovídat jedinečné uživatelské jméno, které existuje ve vybraném poskytovateli identity.", "emailOptional": "E-mail (nepovinné)", "nameOptional": "Jméno (nepovinné)", "accessControls": "Kontrola přístupu", "userDescription2": "Spravovat nastavení tohoto uživatele", "accessRoleErrorAdd": "Přidání uživatele do role se nezdařilo", "accessRoleErrorAddDescription": "Došlo k chybě při přidávání uživatele do role.", "userSaved": "Uživatel uložen", "userSavedDescription": "Uživatel byl aktualizován.", "autoProvisioned": "Automaticky poskytnuto", "autoProvisionedDescription": "Povolit tomuto uživateli automaticky spravovat poskytovatel identity", "accessControlsDescription": "Spravovat co může tento uživatel přistupovat a dělat v organizaci", "accessControlsSubmit": "Uložit kontroly přístupu", "roles": "Role", "accessUsersRoles": "Spravovat uživatele a role", "accessUsersRolesDescription": "Pozvěte uživatele a přidejte je do rolí pro správu přístupu k organizaci", "key": "Klíč", "createdAt": "Vytvořeno v", "proxyErrorInvalidHeader": "Neplatná hodnota hlavičky hostitele. Použijte formát názvu domény, nebo uložte prázdné pro zrušení vlastního hlavičky hostitele.", "proxyErrorTls": "Neplatné jméno TLS serveru. Použijte formát doménového jména nebo uložte prázdné pro odstranění názvu TLS serveru.", "proxyEnableSSL": "Povolit SSL", "proxyEnableSSLDescription": "Povolit šifrování SSL/TLS pro zabezpečená připojení HTTPS k cílům.", "target": "Target", "configureTarget": "Konfigurace cílů", "targetErrorFetch": "Nepodařilo se načíst cíle", "targetErrorFetchDescription": "Při načítání cílů došlo k chybě", "siteErrorFetch": "Nepodařilo se načíst zdroj", "siteErrorFetchDescription": "Při načítání zdroje došlo k chybě", "targetErrorDuplicate": "Duplicate target", "targetErrorDuplicateDescription": "Cíl s těmito nastaveními již existuje", "targetWireGuardErrorInvalidIp": "Invalid target IP", "targetWireGuardErrorInvalidIpDescription": "Cílová IP adresa musí být v podsíti webu", "targetsUpdated": "Cíle byly aktualizovány", "targetsUpdatedDescription": "Cíle a nastavení byly úspěšně aktualizovány", "targetsErrorUpdate": "Nepodařilo se aktualizovat cíle", "targetsErrorUpdateDescription": "Došlo k chybě při aktualizaci cílů", "targetTlsUpdate": "Nastavení TLS aktualizováno", "targetTlsUpdateDescription": "TLS nastavení bylo úspěšně aktualizováno", "targetErrorTlsUpdate": "Aktualizace nastavení TLS se nezdařila", "targetErrorTlsUpdateDescription": "Došlo k chybě při aktualizaci nastavení TLS", "proxyUpdated": "Nastavení proxy bylo aktualizováno", "proxyUpdatedDescription": "Nastavení proxy bylo úspěšně aktualizováno", "proxyErrorUpdate": "Aktualizace nastavení proxy se nezdařila", "proxyErrorUpdateDescription": "Došlo k chybě při aktualizaci nastavení proxy", "targetAddr": "Hostitel", "targetPort": "Přístav", "targetProtocol": "Protokol", "targetTlsSettings": "Nastavení bezpečného připojení", "targetTlsSettingsDescription": "Nastavení SSL/TLS pro zdroj", "targetTlsSettingsAdvanced": "Pokročilé nastavení TLS", "targetTlsSni": "Název serveru TLS", "targetTlsSniDescription": "Název serveru TLS pro použití v SNI. Ponechte prázdné pro použití výchozího nastavení.", "targetTlsSubmit": "Uložit nastavení", "targets": "Konfigurace cílů", "targetsDescription": "Nastavte cíle pro trasu provozu do záložních služeb", "targetStickySessions": "Povolit Rychlé relace", "targetStickySessionsDescription": "Zachovat spojení na stejném cíli pro celou relaci.", "methodSelect": "Vyberte metodu", "targetSubmit": "Add Target", "targetNoOne": "Tento zdroj nemá žádné cíle. Přidejte cíl pro konfiguraci kam poslat žádosti na backend.", "targetNoOneDescription": "Přidáním více než jednoho cíle se umožní vyvážení zatížení.", "targetsSubmit": "Uložit cíle", "addTarget": "Add Target", "targetErrorInvalidIp": "Neplatná IP adresa", "targetErrorInvalidIpDescription": "Zadejte prosím platnou IP adresu nebo název hostitele", "targetErrorInvalidPort": "Neplatný port", "targetErrorInvalidPortDescription": "Zadejte platné číslo portu", "targetErrorNoSite": "Není vybrán žádný web", "targetErrorNoSiteDescription": "Vyberte prosím web pro cíl", "targetCreated": "Cíl byl vytvořen", "targetCreatedDescription": "Cíl byl úspěšně vytvořen", "targetErrorCreate": "Nepodařilo se vytvořit cíl", "targetErrorCreateDescription": "Došlo k chybě při vytváření cíle", "tlsServerName": "Název serveru TLS", "tlsServerNameDescription": "Název serveru TLS pro SNI", "save": "Uložit", "proxyAdditional": "Další nastavení proxy", "proxyAdditionalDescription": "Konfigurovat nastavení proxy", "proxyCustomHeader": "Vlastní hlavička hostitele", "proxyCustomHeaderDescription": "Hlavička hostitele bude nastavena při proxování požadavků. Nechte prázdné pro použití výchozího nastavení.", "proxyAdditionalSubmit": "Uložit nastavení proxy", "subnetMaskErrorInvalid": "Neplatná maska subsítě. Musí být mezi 0 a 32.", "ipAddressErrorInvalidFormat": "Neplatný formát IP adresy", "ipAddressErrorInvalidOctet": "Neplatná IP adresa octet", "path": "Cesta", "matchPath": "Cesta k zápasu", "ipAddressRange": "Rozsah IP", "rulesErrorFetch": "Nepodařilo se načíst pravidla", "rulesErrorFetchDescription": "Při načítání pravidel došlo k chybě", "rulesErrorDuplicate": "Duplikovat pravidlo", "rulesErrorDuplicateDescription": "Pravidlo s těmito nastaveními již existuje", "rulesErrorInvalidIpAddressRange": "Neplatný CIDR", "rulesErrorInvalidIpAddressRangeDescription": "Zadejte prosím platnou hodnotu CIDR", "rulesErrorInvalidUrl": "Neplatná URL cesta", "rulesErrorInvalidUrlDescription": "Zadejte platnou hodnotu URL cesty", "rulesErrorInvalidIpAddress": "Neplatná IP adresa", "rulesErrorInvalidIpAddressDescription": "Zadejte prosím platnou IP adresu", "rulesErrorUpdate": "Aktualizace pravidel se nezdařila", "rulesErrorUpdateDescription": "Při aktualizaci pravidel došlo k chybě", "rulesUpdated": "Povolit pravidla", "rulesUpdatedDescription": "Hodnocení pravidel bylo aktualizováno", "rulesMatchIpAddressRangeDescription": "Zadejte adresu ve formátu CIDR (např. 103.21.244.0/22)", "rulesMatchIpAddress": "Zadejte IP adresu (např. 103.21.244.12)", "rulesMatchUrl": "Zadejte URL cestu nebo vzor (např. /api/v1/todos nebo /api/v1/*)", "rulesErrorInvalidPriority": "Neplatná Priorita", "rulesErrorInvalidPriorityDescription": "Zadejte prosím platnou prioritu", "rulesErrorDuplicatePriority": "Duplikovat priority", "rulesErrorDuplicatePriorityDescription": "Zadejte prosím unikátní priority", "ruleUpdated": "Pravidla byla aktualizována", "ruleUpdatedDescription": "Pravidla byla úspěšně aktualizována", "ruleErrorUpdate": "Operace selhala", "ruleErrorUpdateDescription": "Při ukládání došlo k chybě", "rulesPriority": "Priorita", "rulesAction": "Akce", "rulesMatchType": "Typ shody", "value": "Hodnota", "rulesAbout": "O pravidlech", "rulesAboutDescription": "Pravidla vám umožňují kontrolovat přístup ke zdrojům na základě souboru kritérií. Můžete vytvořit pravidla pro povolení nebo zamítnutí přístupu na základě IP adresy nebo cesty URL.", "rulesActions": "Akce", "rulesActionAlwaysAllow": "Vždy Povolit: Obejít všechny metody ověřování", "rulesActionAlwaysDeny": "Vždy odepří: Zablokovat všechny požadavky; nelze se pokusit o ověření", "rulesActionPassToAuth": "Pass to Auth: Povolit autentizační metody", "rulesMatchCriteria": "Odpovídající kritéria", "rulesMatchCriteriaIpAddress": "Porovnat konkrétní IP adresu", "rulesMatchCriteriaIpAddressRange": "Odpovídá rozsahu IP adres v CIDR značení", "rulesMatchCriteriaUrl": "Porovnejte URL cestu nebo gesto", "rulesEnable": "Povolit pravidla", "rulesEnableDescription": "Povolit nebo zakázat hodnocení pravidel pro tento zdroj", "rulesResource": "Konfigurace pravidel zdroje", "rulesResourceDescription": "Nastavit pravidla pro kontrolu přístupu ke zdroji", "ruleSubmit": "Přidat pravidlo", "rulesNoOne": "Žádná pravidla. Přidejte pravidlo pomocí formuláře.", "rulesOrder": "Pravidla jsou hodnocena podle priority vzestupně.", "rulesSubmit": "Uložit pravidla", "resourceErrorCreate": "Chyba při vytváření zdroje", "resourceErrorCreateDescription": "Při vytváření zdroje došlo k chybě", "resourceErrorCreateMessage": "Chyba při vytváření zdroje:", "resourceErrorCreateMessageDescription": "Došlo k neočekávané chybě", "sitesErrorFetch": "Chyba při načítání stránek", "sitesErrorFetchDescription": "Při načítání stránek došlo k chybě", "domainsErrorFetch": "Chyba při načítání domén", "domainsErrorFetchDescription": "Při načítání domén došlo k chybě", "none": "Nic", "unknown": "Neznámý", "resources": "Zdroje", "resourcesDescription": "Zdroje jsou proxy aplikací běžících na soukromé síti. Vytvořte zdroj pro jakoukoli HTTP/HTTPS nebo nakreslete TCP/UDP službu v soukromé síti. Každý zdroj musí být připojen k webu pro povolení soukromého, zabezpečeného připojení pomocí šifrovaného tunelu WireGuard.", "resourcesWireGuardConnect": "Bezpečné připojení s šifrováním WireGuard", "resourcesMultipleAuthenticationMethods": "Konfigurace vícenásobných metod ověřování", "resourcesUsersRolesAccess": "Kontrola přístupu na základě uživatelů a rolí", "resourcesErrorUpdate": "Nepodařilo se přepnout zdroj", "resourcesErrorUpdateDescription": "Došlo k chybě při aktualizaci zdroje", "access": "Přístup", "accessControl": "Kontrola přístupu", "shareLink": "{resource} Sdílet odkaz", "resourceSelect": "Vyberte zdroj", "shareLinks": "Sdílet odkazy", "share": "Sdílené odkazy", "shareDescription2": "Vytvořte sdílitelné odkazy na zdroje. Odkazy poskytují dočasný nebo neomezený přístup k vašemu zdroji. Můžete nakonfigurovat dobu vypršení platnosti odkazu při jeho vytvoření.", "shareEasyCreate": "Snadné vytváření a sdílení", "shareConfigurableExpirationDuration": "Konfigurovatelná doba vypršení platnosti", "shareSecureAndRevocable": "Bezpečné a odvolatelné", "nameMin": "Jméno musí být alespoň {len} znaků.", "nameMax": "Název nesmí být delší než {len} znaků.", "sitesConfirmCopy": "Potvrďte, že jste zkopírovali konfiguraci.", "unknownCommand": "Neznámý příkaz", "newtErrorFetchReleases": "Nepodařilo se načíst informace o vydání: {err}", "newtErrorFetchLatest": "Chyba při načítání nejnovější verze: {err}", "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Tajný klíč", "architecture": "Architektura", "sites": "Stránky", "siteWgAnyClients": "K připojení použijte jakéhokoli klienta WireGuard. Budete muset řešit interní zdroje pomocí klientské IP adresy.", "siteWgCompatibleAllClients": "Kompatibilní se všemi klienty aplikace WireGuard", "siteWgManualConfigurationRequired": "Je vyžadována ruční konfigurace", "userErrorNotAdminOrOwner": "Uživatel není administrátor nebo vlastník", "pangolinSettings": "Nastavení - Pangolin", "accessRoleYour": "Vaše role:", "accessRoleSelect2": "Vybrat role", "accessUserSelect": "Vybrat uživatele", "otpEmailEnter": "Zadejte e-mail", "otpEmailEnterDescription": "Stisknutím klávesy Enter přidáte e-mail po zadání do vstupního pole.", "otpEmailErrorInvalid": "Neplatná e-mailová adresa. Wildcard (*) musí být celá místní část.", "otpEmailSmtpRequired": "Vyžadováno SMTP", "otpEmailSmtpRequiredDescription": "Pro použití jednorázového ověření heslem musí být na serveru povolen SMTP.", "otpEmailTitle": "Jednorázová hesla", "otpEmailTitleDescription": "Vyžadovat ověření pomocí e-mailu pro přístup ke zdrojům", "otpEmailWhitelist": "Whitelist e-mailu", "otpEmailWhitelistList": "Povolené e-maily", "otpEmailWhitelistListDescription": "K tomuto zdroji budou mít přístup pouze uživatelé s těmito e-mailovými adresami. Budou vyzváni k zadání jednorázového hesla, které bude odesláno na svůj e-mail. Wildcards (*@example.com) lze použít pro povolení jakékoli e-mailové adresy z domény.", "otpEmailWhitelistSave": "Uložit seznam povolených", "passwordAdd": "Přidat heslo", "passwordRemove": "Odstranit heslo", "pincodeAdd": "Přidat PIN kód", "pincodeRemove": "Odstranit PIN kód", "resourceAuthMethods": "Metody ověřování", "resourceAuthMethodsDescriptions": "Povolit přístup ke zdroji pomocí dodatečných metod autorizace", "resourceAuthSettingsSave": "Úspěšně uloženo", "resourceAuthSettingsSaveDescription": "Nastavení ověřování bylo uloženo", "resourceErrorAuthFetch": "Nepodařilo se načíst data", "resourceErrorAuthFetchDescription": "Při načítání dat došlo k chybě", "resourceErrorPasswordRemove": "Chyba při odstraňování hesla zdroje", "resourceErrorPasswordRemoveDescription": "Došlo k chybě při odstraňování hesla", "resourceErrorPasswordSetup": "Chyba při nastavování hesla", "resourceErrorPasswordSetupDescription": "Při nastavování hesla došlo k chybě", "resourceErrorPincodeRemove": "Chyba při odstraňování zdrojového kódu", "resourceErrorPincodeRemoveDescription": "Došlo k chybě při odstraňování zdroje pincode", "resourceErrorPincodeSetup": "Chyba při nastavení zdrojového PIN kódu", "resourceErrorPincodeSetupDescription": "Došlo k chybě při nastavování zdrojového PIN kódu", "resourceErrorUsersRolesSave": "Nepodařilo se nastavit role", "resourceErrorUsersRolesSaveDescription": "Při nastavování rolí došlo k chybě", "resourceErrorWhitelistSave": "Nepodařilo se uložit seznam povolených", "resourceErrorWhitelistSaveDescription": "Došlo k chybě při ukládání seznamu povolených", "resourcePasswordSubmit": "Povolit ochranu heslem", "resourcePasswordProtection": "Ochrana hesla {status}", "resourcePasswordRemove": "Heslo zdroje odstraněno", "resourcePasswordRemoveDescription": "Heslo zdroje bylo úspěšně odstraněno", "resourcePasswordSetup": "Heslo zdroje nastaveno", "resourcePasswordSetupDescription": "Heslo zdroje bylo úspěšně nastaveno", "resourcePasswordSetupTitle": "Nastavit heslo", "resourcePasswordSetupTitleDescription": "Nastavte heslo pro ochranu tohoto zdroje", "resourcePincode": "PIN kód", "resourcePincodeSubmit": "Povolit ochranu PIN kódu", "resourcePincodeProtection": "Ochrana kódu PIN {status}", "resourcePincodeRemove": "Zdroj byl odstraněn", "resourcePincodeRemoveDescription": "Heslo zdroje bylo úspěšně odstraněno", "resourcePincodeSetup": "PIN kód zdroje nastaven", "resourcePincodeSetupDescription": "Zdroj byl úspěšně nastaven", "resourcePincodeSetupTitle": "Nastavit anonymní kód", "resourcePincodeSetupTitleDescription": "Nastavit pincode pro ochranu tohoto zdroje", "resourceRoleDescription": "Administrátoři mají vždy přístup k tomuto zdroji.", "resourceUsersRoles": "Kontrola přístupu", "resourceUsersRolesDescription": "Nastavení, kteří uživatelé a role mohou navštívit tento zdroj", "resourceUsersRolesSubmit": "Uložit přístupové řízení", "resourceWhitelistSave": "Úspěšně uloženo", "resourceWhitelistSaveDescription": "Nastavení seznamu povolených položek bylo uloženo", "ssoUse": "Použít platformu SSO", "ssoUseDescription": "Existující uživatelé se budou muset přihlásit pouze jednou pro všechny zdroje, které jsou povoleny.", "proxyErrorInvalidPort": "Neplatné číslo portu", "subdomainErrorInvalid": "Neplatná subdoména", "domainErrorFetch": "Chyba při načítání domén", "domainErrorFetchDescription": "Při načítání domén došlo k chybě", "resourceErrorUpdate": "Aktualizace zdroje se nezdařila", "resourceErrorUpdateDescription": "Došlo k chybě při aktualizaci zdroje", "resourceUpdated": "Zdroj byl aktualizován", "resourceUpdatedDescription": "Zdroj byl úspěšně aktualizován", "resourceErrorTransfer": "Nepodařilo se přenést dokument", "resourceErrorTransferDescription": "Došlo k chybě při přenosu zdroje", "resourceTransferred": "Přenesené zdroje", "resourceTransferredDescription": "Zdroj byl úspěšně přenesen", "resourceErrorToggle": "Nepodařilo se přepnout zdroj", "resourceErrorToggleDescription": "Došlo k chybě při aktualizaci zdroje", "resourceVisibilityTitle": "Viditelnost", "resourceVisibilityTitleDescription": "Zcela povolit nebo zakázat viditelnost zdrojů", "resourceGeneral": "Obecná nastavení", "resourceGeneralDescription": "Konfigurace obecných nastavení tohoto zdroje", "resourceEnable": "Povolit dokument", "resourceTransfer": "Přenos zdroje", "resourceTransferDescription": "Přenést tento zdroj na jiný web", "resourceTransferSubmit": "Přenos zdroje", "siteDestination": "Cílová stránka", "searchSites": "Hledat lokality", "countries": "Země", "accessRoleCreate": "Vytvořit roli", "accessRoleCreateDescription": "Vytvořte novou roli pro seskupení uživatelů a spravujte jejich oprávnění.", "accessRoleEdit": "Upravit roli", "accessRoleEditDescription": "Upravit informace o roli.", "accessRoleCreateSubmit": "Vytvořit roli", "accessRoleCreated": "Role vytvořena", "accessRoleCreatedDescription": "Role byla úspěšně vytvořena.", "accessRoleErrorCreate": "Nepodařilo se vytvořit roli", "accessRoleErrorCreateDescription": "Došlo k chybě při vytváření role.", "accessRoleUpdateSubmit": "Aktualizovat roli", "accessRoleUpdated": "Role aktualizována", "accessRoleUpdatedDescription": "Role byla úspěšně aktualizována.", "accessApprovalUpdated": "Zpracovaná schválení", "accessApprovalApprovedDescription": "Nastavit rozhodnutí o schválení žádosti o schválení.", "accessApprovalDeniedDescription": "Nastavit žádost o schválení rozhodnutí o zamítnutí.", "accessRoleErrorUpdate": "Nepodařilo se aktualizovat roli", "accessRoleErrorUpdateDescription": "Došlo k chybě při aktualizaci role.", "accessApprovalErrorUpdate": "Zpracování schválení se nezdařilo", "accessApprovalErrorUpdateDescription": "Při zpracování schválení došlo k chybě.", "accessRoleErrorNewRequired": "Je vyžadována nová role", "accessRoleErrorRemove": "Nepodařilo se odstranit roli", "accessRoleErrorRemoveDescription": "Došlo k chybě při odstraňování role.", "accessRoleName": "Název role", "accessRoleQuestionRemove": "Chystáte se odstranit roli `{name}`. Tuto akci nelze vrátit zpět.", "accessRoleRemove": "Odstranit roli", "accessRoleRemoveDescription": "Odebrat roli z organizace", "accessRoleRemoveSubmit": "Odstranit roli", "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", "licenseTierProfessional": "Profesionální licence", "licenseTierEnterprise": "Podniková licence", "licenseTierPersonal": "Osobní licence", "licensed": "Licencováno", "yes": "Ano", "no": "Ne", "sitesAdditional": "Další weby", "licenseKeys": "Licenční klíče", "sitestCountDecrease": "Snížit počet stránek", "sitestCountIncrease": "Zvýšit počet stránek", "idpManage": "Spravovat poskytovatele identity", "idpManageDescription": "Zobrazit a spravovat poskytovatele identity v systému", "idpGlobalModeBanner": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Správa globálních IdP v admin panelu. Chcete-li povolit IdP pro každou organizaci, upravte konfiguraci serveru a nastavte IdP režim na org. Viz dokumentace. Pokud chcete pokračovat v používání globálních IdP a zmizet z nastavení organizace, explicitně nastavte režim na globální v konfiguraci.", "idpGlobalModeBannerUpgradeRequired": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Spravujte globální IdP v admin panelu. Chcete-li použít poskytovatele identity pro každou organizaci, musíte přejít na Enterprise vydání.", "idpGlobalModeBannerLicenseRequired": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Správa globálních IdP v admin panelu. Chcete-li použít poskytovatele identity pro každou organizaci, je vyžadována Enterprise licence.", "idpDeletedDescription": "Poskytovatel identity byl úspěšně odstraněn", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Jste si jisti, že chcete trvale odstranit poskytovatele identity?", "idpMessageRemove": "Tímto odstraníte poskytovatele identity a všechny přidružené konfigurace. Uživatelé, kteří se autentizují prostřednictvím tohoto poskytovatele, se již nebudou moci přihlásit.", "idpMessageConfirm": "Pro potvrzení zadejte níže uvedené jméno poskytovatele identity.", "idpConfirmDelete": "Potvrdit odstranění poskytovatele identity", "idpDelete": "Odstranit poskytovatele identity", "idp": "Poskytovatelé identity", "idpSearch": "Hledat poskytovatele identity...", "idpAdd": "Přidat poskytovatele identity", "idpClientIdRequired": "Je vyžadováno ID klienta.", "idpClientSecretRequired": "Tajný klíč klienta je povinný.", "idpErrorAuthUrlInvalid": "Ověřovací URL musí být platná adresa URL.", "idpErrorTokenUrlInvalid": "URL tokenu musí být platná adresa URL.", "idpPathRequired": "Je vyžadována cesta identifikátora.", "idpScopeRequired": "Rozsahy jsou povinné.", "idpOidcDescription": "Konfigurace OpenID Connect identitu poskytovatele", "idpCreatedDescription": "Poskytovatel identity byl úspěšně vytvořen", "idpCreate": "Vytvořit poskytovatele identity", "idpCreateDescription": "Konfigurace nového poskytovatele identity pro ověření uživatele", "idpSeeAll": "Zobrazit všechny poskytovatele identity", "idpSettingsDescription": "Konfigurace základních informací pro svého poskytovatele identity", "idpDisplayName": "Zobrazované jméno tohoto poskytovatele identity", "idpAutoProvisionUsers": "Automatická úprava uživatelů", "idpAutoProvisionUsersDescription": "Pokud je povoleno, uživatelé budou automaticky vytvářeni v systému při prvním přihlášení, s možností namapovat uživatele na role a organizace.", "licenseBadge": "PE", "idpType": "Typ poskytovatele", "idpTypeDescription": "Vyberte typ poskytovatele identity, který chcete nakonfigurovat", "idpOidcConfigure": "Nastavení OAuth2/OIDC", "idpOidcConfigureDescription": "Konfigurace koncových bodů a pověření poskytovatele OAuth2/OIDC", "idpClientId": "ID klienta", "idpClientIdDescription": "Klientské ID OAuth2 od poskytovatele identity", "idpClientSecret": "Tajný klíč klienta", "idpClientSecretDescription": "OAuth2 heslo klienta od poskytovatele identity", "idpAuthUrl": "Autorizační URL", "idpAuthUrlDescription": "Koncový koncový bod ověření OAuth2", "idpTokenUrl": "URL tokenu", "idpTokenUrlDescription": "URL koncového bodu OAuth2", "idpOidcConfigureAlert": "Důležité informace", "idpOidcConfigureAlertDescription": "Po vytvoření poskytovatele identity budete muset nakonfigurovat URL zpětného volání v nastavení poskytovatele identity. Adresa URL zpětného volání bude poskytnuta po úspěšném vytvoření.", "idpToken": "Nastavení tokenu", "idpTokenDescription": "Konfigurovat jak extrahovat informace o uživateli z ID tokenu", "idpJmespathAbout": "O JMESPath", "idpJmespathAboutDescription": "Cesty níže používají JMESPath syntax pro extrakci hodnot z ID tokenu.", "idpJmespathAboutDescriptionLink": "Další informace o cestě JMESPath", "idpJmespathLabel": "Cesta identifikátoru", "idpJmespathLabelDescription": "Cesta k identifikátoru uživatele v tokenu ID", "idpJmespathEmailPathOptional": "Cesta e-mailu (volitelné)", "idpJmespathEmailPathOptionalDescription": "Cesta k e-mailu uživatele v ID tokenu", "idpJmespathNamePathOptional": "Cesta k názvu (volitelné)", "idpJmespathNamePathOptionalDescription": "Cesta ke jménu uživatele v identifikačním tokenu", "idpOidcConfigureScopes": "Rozsah", "idpOidcConfigureScopesDescription": "Seznam OAuth2 rozsahů oddělených mezerou", "idpSubmit": "Vytvořit poskytovatele identity", "orgPolicies": "Zásady organizace", "idpSettings": "Nastavení {idpName}", "idpCreateSettingsDescription": "Konfigurace nastavení poskytovatele identity", "roleMapping": "Mapování rolí", "orgMapping": "Mapování organizace", "orgPoliciesSearch": "Hledat v zásadách organizace...", "orgPoliciesAdd": "Přidat zásady organizace", "orgRequired": "Organizace je povinná", "error": "Chyba", "success": "Úspěšně", "orgPolicyAddedDescription": "Politika byla úspěšně přidána", "orgPolicyUpdatedDescription": "Zásady byly úspěšně aktualizovány", "orgPolicyDeletedDescription": "Zásady byly úspěšně odstraněny", "defaultMappingsUpdatedDescription": "Výchozí mapování bylo úspěšně aktualizováno", "orgPoliciesAbout": "O zásadách organizace", "orgPoliciesAboutDescription": "Zásady organizace se používají pro kontrolu přístupu do organizací na základě identifikačního tokenu. Můžete zadat JMESPath výrazy pro extrahování rolí a informací o organizaci z ID tokenu.", "orgPoliciesAboutDescriptionLink": "Další informace naleznete v dokumentaci.", "defaultMappingsOptional": "Výchozí mapování (volitelné)", "defaultMappingsOptionalDescription": "Výchozí mapování se používá, pokud nejsou pro organizaci definovány zásady organizace. Můžete zadat výchozí roli a mapování organizace pro návrat zde.", "defaultMappingsRole": "Výchozí mapování rolí", "defaultMappingsRoleDescription": "Výsledek tohoto výrazu musí vrátit název role definovaný v organizaci jako řetězec.", "defaultMappingsOrg": "Výchozí mapování organizace", "defaultMappingsOrgDescription": "Tento výraz musí vrátit org ID nebo pravdu, aby měl uživatel přístup k organizaci.", "defaultMappingsSubmit": "Uložit výchozí mapování", "orgPoliciesEdit": "Upravit zásady organizace", "org": "Organizace", "orgSelect": "Vyberte organizaci", "orgSearch": "Hledat v org", "orgNotFound": "Nenalezeny žádné org.", "roleMappingPathOptional": "Cesta k mapování rolí (volitelné)", "orgMappingPathOptional": "Cesta k mapování organizace (volitelné)", "orgPolicyUpdate": "Aktualizovat přístupové právo", "orgPolicyAdd": "Přidat přístupové právo", "orgPolicyConfig": "Konfigurace přístupu pro organizaci", "idpUpdatedDescription": "Poskytovatel identity byl úspěšně aktualizován", "redirectUrl": "Přesměrovat URL", "orgIdpRedirectUrls": "Přesměrovat URL", "redirectUrlAbout": "O přesměrování URL", "redirectUrlAboutDescription": "Toto je URL, na kterou budou uživatelé po ověření přesměrováni. Tuto URL je třeba nastavit v nastavení poskytovatele identity.", "pangolinAuth": "Auth - Pangolin", "verificationCodeLengthRequirements": "Váš ověřovací kód musí mít 8 znaků.", "errorOccurred": "Došlo k chybě", "emailErrorVerify": "Nepodařilo se ověřit e-mail:", "emailVerified": "E-mail byl úspěšně ověřen! Přesměrování vás...", "verificationCodeErrorResend": "Nepodařilo se znovu odeslat ověřovací kód:", "verificationCodeResend": "Ověřovací kód znovu odeslán", "verificationCodeResendDescription": "Znovu jsme odeslali ověřovací kód na vaši e-mailovou adresu. Zkontrolujte prosím svou doručenou poštu.", "emailVerify": "Ověřit e-mail", "emailVerifyDescription": "Zadejte ověřovací kód zaslaný na vaši e-mailovou adresu.", "verificationCode": "Ověřovací kód", "verificationCodeEmailSent": "Na vaši e-mailovou adresu jsme zaslali ověřovací kód.", "submit": "Odeslat", "emailVerifyResendProgress": "Znovu odesílání...", "emailVerifyResend": "Neobdrželi jste kód? Klikněte zde pro opětovné odeslání", "passwordNotMatch": "Hesla se neshodují", "signupError": "Při registraci došlo k chybě", "pangolinLogoAlt": "Logo Pangolin", "inviteAlready": "Vypadá to, že jste byli pozváni!", "inviteAlreadyDescription": "Chcete-li přijmout pozvánku, musíte se přihlásit nebo vytvořit účet.", "signupQuestion": "Již máte účet?", "login": "Přihlásit se", "resourceNotFound": "Zdroj nebyl nalezen", "resourceNotFoundDescription": "Dokument, ke kterému se snažíte přistupovat, neexistuje.", "pincodeRequirementsLength": "PIN musí být přesně 6 číslic", "pincodeRequirementsChars": "PIN musí obsahovat pouze čísla", "passwordRequirementsLength": "Heslo musí mít alespoň 1 znak", "passwordRequirementsTitle": "Požadavky na hesla:", "passwordRequirementLength": "Nejméně 8 znaků dlouhé", "passwordRequirementUppercase": "Alespoň jedno velké písmeno", "passwordRequirementLowercase": "Alespoň jedno malé písmeno", "passwordRequirementNumber": "Alespoň jedno číslo", "passwordRequirementSpecial": "Alespoň jeden speciální znak", "passwordRequirementsMet": "✓ Heslo splňuje všechny požadavky", "passwordStrength": "Síla hesla", "passwordStrengthWeak": "Slabé", "passwordStrengthMedium": "Střední", "passwordStrengthStrong": "Silný", "passwordRequirements": "Požadavky:", "passwordRequirementLengthText": "8+ znaků", "passwordRequirementUppercaseText": "Velké písmeno (A-Z)", "passwordRequirementLowercaseText": "Malé písmeno (a-z)", "passwordRequirementNumberText": "Číslo (0-9)", "passwordRequirementSpecialText": "Special character (!@#$%...)", "passwordsDoNotMatch": "Hesla se neshodují", "otpEmailRequirementsLength": "OTP musí mít alespoň 1 znak", "otpEmailSent": "OTP odesláno", "otpEmailSentDescription": "OTP bylo odesláno na váš e-mail", "otpEmailErrorAuthenticate": "Ověření pomocí e-mailu se nezdařilo", "pincodeErrorAuthenticate": "Ověření pomocí pincode se nezdařilo", "passwordErrorAuthenticate": "Ověření pomocí hesla se nezdařilo", "poweredBy": "Běží na", "authenticationRequired": "Vyžadována autentizace", "authenticationMethodChoose": "Vyberte si preferovanou metodu pro přístup k {name}", "authenticationRequest": "Musíte se přihlásit k přístupu {name}", "user": "Uživatel", "pincodeInput": "6místný PIN kód", "pincodeSubmit": "Přihlásit se pomocí PIN", "passwordSubmit": "Přihlásit se pomocí hesla", "otpEmailDescription": "Na tento e-mail bude zaslán jednorázový kód.", "otpEmailSend": "Poslat jednorázový kód", "otpEmail": "Jednorázové heslo (OTP)", "otpEmailSubmit": "Odeslat OTP", "backToEmail": "Zpět na e-mail", "noSupportKey": "Server běží bez klíče podporovatele. Zvažte podporu projektu!", "accessDenied": "Přístup odepřen", "accessDeniedDescription": "Nemáte oprávnění k přístupu k tomuto dokumentu. Pokud se jedná o chybu, obraťte se na správce.", "accessTokenError": "Chyba při kontrole přístupu token", "accessGranted": "Přístup povolen", "accessUrlInvalid": "Neplatná URL adresa přístupu", "accessGrantedDescription": "Byl vám udělen přístup k tomuto zdroji. Přesměrování vás...", "accessUrlInvalidDescription": "Tato URL adresa sdíleného přístupu je neplatná. Kontaktujte prosím vlastníka zdroje pro novou URL.", "tokenInvalid": "Neplatný token", "pincodeInvalid": "Neplatný kód", "passwordErrorRequestReset": "Nepodařilo se požádat o reset:", "passwordErrorReset": "Obnovení hesla selhalo:", "passwordResetSuccess": "Obnovení hesla úspěšné! Zpět do přihlášení...", "passwordReset": "Obnovit heslo", "passwordResetDescription": "Postupujte podle kroků pro obnovení hesla", "passwordResetSent": "Na tuto e-mailovou adresu zašleme kód pro obnovení hesla.", "passwordResetCode": "Reset Code", "passwordResetCodeDescription": "Zkontrolujte svůj e-mail pro kód pro obnovení.", "generatePasswordResetCode": "Vygenerovat kód pro obnovení hesla", "passwordResetCodeGenerated": "Kód pro obnovení hesla byl vytvořen", "passwordResetCodeGeneratedDescription": "Sdílejte tento kód s uživatelem. Mohou jej použít k obnovení hesla.", "passwordResetUrl": "Reset URL", "passwordNew": "Nové heslo", "passwordNewConfirm": "Potvrdit nové heslo", "changePassword": "Změnit heslo", "changePasswordDescription": "Aktualizujte heslo k účtu", "oldPassword": "Aktuální heslo", "newPassword": "Nové heslo", "confirmNewPassword": "Potvrdit nové heslo", "changePasswordError": "Změna hesla se nezdařila", "changePasswordErrorDescription": "Došlo k chybě při změně hesla", "changePasswordSuccess": "Heslo úspěšně změněno", "changePasswordSuccessDescription": "Vaše heslo bylo úspěšně aktualizováno", "passwordExpiryRequired": "Vyžaduje vypršení platnosti hesla", "passwordExpiryDescription": "Tato organizace vyžaduje změnu hesla každých {maxDays} dní.", "changePasswordNow": "Změnit heslo", "pincodeAuth": "Ověřovací kód", "pincodeSubmit2": "Odeslat kód", "passwordResetSubmit": "Žádost o obnovení", "passwordResetAlreadyHaveCode": "Zadejte kód", "passwordResetSmtpRequired": "Obraťte se na správce", "passwordResetSmtpRequiredDescription": "Pro obnovení hesla je vyžadován kód pro obnovení hesla. Kontaktujte prosím svého administrátora.", "passwordBack": "Zpět na heslo", "loginBack": "Přejít zpět na hlavní přihlašovací stránku", "signup": "Zaregistrovat se", "loginStart": "Přihlaste se a začněte", "idpOidcTokenValidating": "Ověřování OIDC tokenu", "idpOidcTokenResponse": "Potvrdit odpověď OIDC tokenu", "idpErrorOidcTokenValidating": "Chyba při ověřování OIDC tokenu", "idpConnectingTo": "Připojování k {name}", "idpConnectingToDescription": "Ověření Vaší identity", "idpConnectingToProcess": "Připojování...", "idpConnectingToFinished": "Připojeno", "idpErrorConnectingTo": "Při připojování k {name}došlo k chybě. Obraťte se na správce.", "idpErrorNotFound": "IdP nenalezen", "inviteInvalid": "Neplatná pozvánka", "inviteInvalidDescription": "Odkaz pro pozvání je neplatný.", "inviteErrorWrongUser": "Pozvat není pro tohoto uživatele", "inviteErrorUserNotExists": "Uživatel neexistuje. Nejprve si vytvořte účet.", "inviteErrorLoginRequired": "Musíte být přihlášeni, abyste mohli přijmout pozvánku", "inviteErrorExpired": "Pozvánka možná vypršela", "inviteErrorRevoked": "Pozvánka mohla být zrušena", "inviteErrorTypo": "Na pozvánce může být typol", "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.", "overview": "Přehled", "home": "Domů", "settings": "Nastavení", "usersAll": "Všichni uživatelé", "license": "Licence", "pangolinDashboard": "Nástěnka - Pangolin", "noResults": "Nebyly nalezeny žádné výsledky.", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "Zadané štítky", "tagsEnteredDescription": "Toto jsou značky, které jste zadali.", "tagsWarnCannotBeLessThanZero": "maxTagy a minTagy nesmí být menší než 0", "tagsWarnNotAllowedAutocompleteOptions": "Označení není povoleno podle možností automatického dokončování", "tagsWarnInvalid": "Neplatný štítek pro ověření tagu", "tagWarnTooShort": "Značka {tagText} je příliš krátká", "tagWarnTooLong": "Značka {tagText} je příliš dlouhá", "tagsWarnReachedMaxNumber": "Dosažen maximální povolený počet štítků", "tagWarnDuplicate": "Duplicitní značka {tagText} nebyla přidána", "supportKeyInvalid": "Neplatný klíč", "supportKeyInvalidDescription": "Váš supporter klíč je neplatný.", "supportKeyValid": "Valid Key", "supportKeyValidDescription": "Váš klíč podporovatele byl ověřen. Děkujeme za vaši podporu!", "supportKeyErrorValidationDescription": "Nepodařilo se ověřit klíč podporovatele.", "supportKey": "Podpořte vývoj a přijměte Pangolin!", "supportKeyDescription": "Kupte klíč podporovatele, který nám pomůže pokračovat ve vývoji Pangolinu pro komunitu. Váš příspěvek nám umožňuje získat více času pro zachování a přidání nových funkcí do aplikace pro všechny. Nikdy to nepoužijeme pro funkce Paywall a to je oddělené od jakékoliv obchodní verze.", "supportKeyPet": "Také se dostanete k adopci a setkáte se s vlastním mazlíčkem Pangolinem!", "supportKeyPurchase": "Platby jsou zpracovávány přes GitHub. Poté můžete získat svůj klíč na", "supportKeyPurchaseLink": "naše webové stránky", "supportKeyPurchase2": "a znovu jej zde vyplatit.", "supportKeyLearnMore": "Zjistěte více.", "supportKeyOptions": "Vyberte si prosím možnost, která vám nejlépe vyhovuje.", "supportKetOptionFull": "Plný podporovatel", "forWholeServer": "Pro celý server", "lifetimePurchase": "Doživotní nákup", "supporterStatus": "Stav podporovatele", "buy": "Koupit", "supportKeyOptionLimited": "Omezený podporovatel", "forFiveUsers": "Pro 5 nebo méně uživatelů", "supportKeyRedeem": "Uplatnit klíč podpory", "supportKeyHideSevenDays": "Skrýt na 7 dní", "supportKeyEnter": "Zadejte klíč podpory", "supportKeyEnterDescription": "Seznamte se s vlastním mazlíčkem Pangolin!", "githubUsername": "GitHub Username", "supportKeyInput": "Klíč pro podporu", "supportKeyBuy": "Koupit klíč pro podporu", "logoutError": "Chyba při odhlášení", "signingAs": "Přihlášen jako", "serverAdmin": "Správce serveru", "managedSelfhosted": "Spravované vlastní hostování", "otpEnable": "Povolit dvoufaktorové", "otpDisable": "Zakázat dvoufaktorové", "logout": "Odhlásit se", "licenseTierProfessionalRequired": "Vyžadována profesionální edice", "licenseTierProfessionalRequiredDescription": "Tato funkce je dostupná pouze v Professional Edition.", "actionGetOrg": "Získat organizaci", "updateOrgUser": "Aktualizovat Org uživatele", "createOrgUser": "Vytvořit Org uživatele", "actionUpdateOrg": "Aktualizovat organizaci", "actionRemoveInvitation": "Odstranit pozvání", "actionUpdateUser": "Aktualizovat uživatele", "actionGetUser": "Získat uživatele", "actionGetOrgUser": "Získat uživatele organizace", "actionListOrgDomains": "Seznam domén organizace", "actionGetDomain": "Získat doménu", "actionCreateOrgDomain": "Vytvořit doménu", "actionUpdateOrgDomain": "Aktualizovat doménu", "actionDeleteOrgDomain": "Odstranit doménu", "actionGetDNSRecords": "Získat záznamy DNS", "actionRestartOrgDomain": "Restartovat doménu", "actionCreateSite": "Vytvořit lokalitu", "actionDeleteSite": "Odstranění lokality", "actionGetSite": "Získat web", "actionListSites": "Seznam stránek", "actionApplyBlueprint": "Použít plán", "actionListBlueprints": "Seznam šablon", "actionGetBlueprint": "Získat šablonu", "setupToken": "Nastavit token", "setupTokenDescription": "Zadejte nastavovací token z konzole serveru.", "setupTokenRequired": "Je vyžadován token nastavení", "actionUpdateSite": "Aktualizovat stránku", "actionListSiteRoles": "Seznam povolených rolí webu", "actionCreateResource": "Vytvořit zdroj", "actionDeleteResource": "Odstranit dokument", "actionGetResource": "Získat dokument", "actionListResource": "Seznam zdrojů", "actionUpdateResource": "Aktualizovat dokument", "actionListResourceUsers": "Seznam uživatelů zdrojů", "actionSetResourceUsers": "Nastavit uživatele zdrojů", "actionSetAllowedResourceRoles": "Nastavit povolené role zdrojů", "actionListAllowedResourceRoles": "Seznam povolených rolí zdrojů", "actionSetResourcePassword": "Nastavit heslo zdroje", "actionSetResourcePincode": "Nastavit zdrojový kód", "actionSetResourceEmailWhitelist": "Nastavit seznam povolených dokumentů", "actionGetResourceEmailWhitelist": "Získat seznam povolených dokumentů", "actionCreateTarget": "Create Target", "actionDeleteTarget": "Odstranit cíl", "actionGetTarget": "Získat cíl", "actionListTargets": "Seznam cílů", "actionUpdateTarget": "Update Target", "actionCreateRole": "Vytvořit roli", "actionDeleteRole": "Odstranit roli", "actionGetRole": "Získat roli", "actionListRole": "Seznam rolí", "actionUpdateRole": "Aktualizovat roli", "actionListAllowedRoleResources": "Seznam povolených zdrojů rolí", "actionInviteUser": "Pozvat uživatele", "actionRemoveUser": "Odstranit uživatele", "actionListUsers": "Seznam uživatelů", "actionAddUserRole": "Přidat uživatelskou roli", "actionGenerateAccessToken": "Generovat přístupový token", "actionDeleteAccessToken": "Odstranit přístupový token", "actionListAccessTokens": "Seznam přístupových tokenů", "actionCreateResourceRule": "Vytvořit pravidlo pro zdroj", "actionDeleteResourceRule": "Odstranit pravidlo pro dokument", "actionListResourceRules": "Seznam pravidel zdrojů", "actionUpdateResourceRule": "Aktualizovat pravidlo pro dokument", "actionListOrgs": "Seznam organizací", "actionCheckOrgId": "ID kontroly", "actionCreateOrg": "Vytvořit organizaci", "actionDeleteOrg": "Odstranit organizaci", "actionListApiKeys": "Seznam API klíčů", "actionListApiKeyActions": "Seznam akcí API klíče", "actionSetApiKeyActions": "Nastavit povolené akce API klíče", "actionCreateApiKey": "Vytvořit klíč API", "actionDeleteApiKey": "Odstranit klíč API", "actionCreateIdp": "Vytvořit IDP", "actionUpdateIdp": "Aktualizovat IDP", "actionDeleteIdp": "Odstranit IDP", "actionListIdps": "ID seznamu", "actionGetIdp": "Získat IDP", "actionCreateIdpOrg": "Vytvořit IDP Org Policy", "actionDeleteIdpOrg": "Odstranit IDP Org Policy", "actionListIdpOrgs": "Seznam IDP orgánů", "actionUpdateIdpOrg": "Aktualizovat IDP Org", "actionCreateClient": "Vytvořit klienta", "actionDeleteClient": "Odstranit klienta", "actionArchiveClient": "Archivovat klienta", "actionUnarchiveClient": "Zrušit archiv klienta", "actionBlockClient": "Blokovat klienta", "actionUnblockClient": "Odblokovat klienta", "actionUpdateClient": "Aktualizovat klienta", "actionListClients": "Seznam klientů", "actionGetClient": "Získat klienta", "actionCreateSiteResource": "Vytvořit zdroj webu", "actionDeleteSiteResource": "Odstranit dokument webu", "actionGetSiteResource": "Získat zdroj webu", "actionListSiteResources": "Seznam zdrojů webu", "actionUpdateSiteResource": "Aktualizovat dokument webu", "actionListInvitations": "Seznam pozvánek", "actionExportLogs": "Exportovat protokoly", "actionViewLogs": "Zobrazit logy", "noneSelected": "Není vybráno", "orgNotFound2": "Nebyly nalezeny žádné organizace.", "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.", "loginRequiredForDevice": "Přihlášení je vyžadováno pro vaše zařízení.", "passwordForgot": "Zapomněli jste heslo?", "otpAuth": "Dvoufaktorové ověření", "otpAuthDescription": "Zadejte kód z vaší autentizační aplikace nebo jeden z vlastních záložních kódů.", "otpAuthSubmit": "Odeslat kód", "idpContinue": "Nebo pokračovat s", "otpAuthBack": "Zpět na heslo", "navbar": "Navigation Menu", "navbarDescription": "Hlavní navigační menu aplikace", "navbarDocsLink": "Dokumentace", "otpErrorEnable": "2FA nelze povolit", "otpErrorEnableDescription": "Došlo k chybě při povolování 2FA", "otpSetupCheckCode": "Zadejte 6místný kód", "otpSetupCheckCodeRetry": "Neplatný kód. Zkuste to prosím znovu.", "otpSetup": "Povolit dvoufaktorové ověření", "otpSetupDescription": "Zabezpečte svůj účet s další vrstvou ochrany", "otpSetupScanQr": "Naskenujte tento QR kód pomocí vaší autentizační aplikace nebo zadejte tajný klíč ručně:", "otpSetupSecretCode": "Ověřovací kód", "otpSetupSuccess": "Dvoufázové ověřování povoleno", "otpSetupSuccessStoreBackupCodes": "Váš účet je nyní bezpečnější. Nezapomeňte uložit své záložní kódy.", "otpErrorDisable": "2FA nelze zakázat", "otpErrorDisableDescription": "Došlo k chybě při zakázání 2FA", "otpRemove": "Zakázat dvoufaktorové ověřování", "otpRemoveDescription": "Zakázat dvoufaktorové ověřování vašeho účtu", "otpRemoveSuccess": "Dvoufázové ověřování zakázáno", "otpRemoveSuccessMessage": "Dvoufaktorové ověřování bylo pro váš účet zakázáno. Můžete jej kdykoliv znovu povolit.", "otpRemoveSubmit": "Zakázat 2FA", "paginator": "Strana {current} z {last}", "paginatorToFirst": "Přejít na první stránku", "paginatorToPrevious": "Přejít na předchozí stránku", "paginatorToNext": "Přejít na další stránku", "paginatorToLast": "Přejít na poslední stránku", "copyText": "Kopírovat text", "copyTextFailed": "Nepodařilo se zkopírovat text: ", "copyTextClipboard": "Kopírovat do schránky", "inviteErrorInvalidConfirmation": "Neplatné potvrzení", "passwordRequired": "Heslo je vyžadováno", "allowAll": "Povolit vše", "permissionsAllowAll": "Povolit všechna oprávnění", "githubUsernameRequired": "Je vyžadováno uživatelské jméno GitHub", "supportKeyRequired": "Je vyžadován klíč podpory", "passwordRequirementsChars": "Heslo musí mít alespoň 8 znaků", "language": "Jazyk", "verificationCodeRequired": "Kód je povinný", "userErrorNoUpdate": "Žádný uživatel k aktualizaci", "siteErrorNoUpdate": "Žádná stránka k aktualizaci", "resourceErrorNoUpdate": "Žádný zdroj k aktualizaci", "authErrorNoUpdate": "Žádné informace o ověření k aktualizaci", "orgErrorNoUpdate": "Žádný z orgů k aktualizaci", "orgErrorNoProvided": "Není k dispozici žádný org", "apiKeysErrorNoUpdate": "Žádný API klíč k aktualizaci", "sidebarOverview": "Přehled", "sidebarHome": "Domů", "sidebarSites": "Stránky", "sidebarApprovals": "Žádosti o schválení", "sidebarResources": "Zdroje", "sidebarProxyResources": "Veřejnost", "sidebarClientResources": "Soukromé", "sidebarAccessControl": "Kontrola přístupu", "sidebarLogsAndAnalytics": "Logy & Analytika", "sidebarTeam": "Tým", "sidebarUsers": "Uživatelé", "sidebarAdmin": "Admin", "sidebarInvitations": "Pozvánky", "sidebarRoles": "Role", "sidebarShareableLinks": "Odkazy", "sidebarApiKeys": "API klíče", "sidebarSettings": "Nastavení", "sidebarAllUsers": "Všichni uživatelé", "sidebarIdentityProviders": "Poskytovatelé identity", "sidebarLicense": "Licence", "sidebarClients": "Klienti", "sidebarUserDevices": "Uživatelská zařízení", "sidebarMachineClients": "Stroje a přístroje", "sidebarDomains": "Domény", "sidebarGeneral": "Spravovat", "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", "blueprintAdd": "Přidat plán", "blueprintGoBack": "Zobrazit všechny plány", "blueprintCreate": "Vytvořit plán", "blueprintCreateDescription2": "Postupujte podle níže uvedených kroků pro vytvoření a použití nového plánu", "blueprintDetails": "Podrobnosti plánu", "blueprintDetailsDescription": "Podívejte se na výsledek použitého plánu a případné chyby, které se vyskytly", "blueprintInfo": "Informace o plánu", "message": "Zpráva", "blueprintContentsDescription": "Definujte obsah YAML popisující infrastrukturu", "blueprintErrorCreateDescription": "Při aplikaci plánu došlo k chybě", "blueprintErrorCreate": "Chyba při vytváření plánu", "searchBlueprintProgress": "Hledat plány...", "appliedAt": "Použito v", "source": "Zdroj", "contents": "Obsah", "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.", "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.", "containerName": "Jméno", "containerImage": "Obrázek", "containerState": "Stát", "containerNetworks": "Síť", "containerHostnameIp": "Hostname/IP", "containerLabels": "Popisky", "containerLabelsCount": "{count, plural, one {# štítek} other {# štítků}}", "containerLabelsTitle": "Popisky kontejneru", "containerLabelEmpty": "", "containerPorts": "Přístavy", "containerPortsMore": "+{count} další", "containerActions": "Akce", "select": "Vybrat", "noContainersMatchingFilters": "Nebyly nalezeny žádné kontejnery odpovídající aktuálním filtrům.", "showContainersWithoutPorts": "Zobrazit kontejnery bez portů", "showStoppedContainers": "Zobrazit zastavené kontejnery", "noContainersFound": "Nebyly nalezeny žádné kontejnery. Ujistěte se, že jsou v kontejnerech Docker spuštěny.", "searchContainersPlaceholder": "Hledat napříč {count} kontejnery...", "searchResultsCount": "{count, plural, one {# výsledek} other {# výsledků}}", "filters": "Filtry", "filterOptions": "Možnosti filtru", "filterPorts": "Přístavy", "filterStopped": "Zastaveno", "clearAllFilters": "Vymazat všechny filtry", "columns": "Sloupce", "toggleColumns": "Přepnout sloupce", "refreshContainersList": "Aktualizovat seznam kontejnerů", "searching": "Vyhledávání...", "noContainersFoundMatching": "Nebyly nalezeny žádné kontejnery odpovídající \"{filter}\".", "light": "lehké", "dark": "tmavé", "system": "systém", "theme": "Téma", "subnetRequired": "Podsíť je vyžadována", "initialSetupTitle": "Počáteční nastavení serveru", "initialSetupDescription": "Vytvořte účet správce intial serveru. Pouze jeden správce serveru může existovat. Tyto přihlašovací údaje můžete kdykoliv změnit.", "createAdminAccount": "Vytvořit účet správce", "setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.", "certificateStatus": "Stav certifikátu", "loading": "Načítání", "loadingAnalytics": "Načítání analytiky", "restart": "Restartovat", "domains": "Domény", "domainsDescription": "Vytvořit a spravovat domény dostupné v organizaci", "domainsSearch": "Hledat domény...", "domainAdd": "Přidat doménu", "domainAddDescription": "Registrovat novou doménu s organizací", "domainCreate": "Vytvořit doménu", "domainCreatedDescription": "Doména byla úspěšně vytvořena", "domainDeletedDescription": "Doména byla úspěšně odstraněna", "domainQuestionRemove": "Jste si jisti, že chcete odstranit doménu?", "domainMessageRemove": "Po odstranění domény již nebude přiřazena k organizaci.", "domainConfirmDelete": "Potvrdit odstranění domény", "domainDelete": "Odstranit doménu", "domain": "Doména", "selectDomainTypeNsName": "Delegace domény (blíže neurčeno)", "selectDomainTypeNsDescription": "Tato doména a všechny její subdomény. Použijte tuto možnost, pokud chcete ovládat celou doménu zóny.", "selectDomainTypeCnameName": "Jedna doména (CNAME)", "selectDomainTypeCnameDescription": "Jen tato konkrétní doména. Použijte tuto možnost pro jednotlivé subdomény nebo konkrétní doménové položky.", "selectDomainTypeWildcardName": "Doména zástupného znaku", "selectDomainTypeWildcardDescription": "Tato doména a její subdomény.", "domainDelegation": "Jedna doména", "selectType": "Vyberte typ", "actions": "Akce", "refresh": "Aktualizovat", "refreshError": "Obnovení dat se nezdařilo", "verified": "Ověřeno", "pending": "Nevyřízeno", "pendingApproval": "Čeká na schválení", "sidebarBilling": "Fakturace", "billing": "Fakturace", "orgBillingDescription": "Spravovat fakturační informace a předplatné", "github": "GitHub", "pangolinHosted": "Pangolin hostovaný", "fossorial": "Fossorální", "completeAccountSetup": "Dokončit nastavení účtu", "completeAccountSetupDescription": "Nastavte heslo pro spuštění", "accountSetupSent": "Na tuto e-mailovou adresu zašleme nastavovací kód účtu.", "accountSetupCode": "Nastavte kód", "accountSetupCodeDescription": "Zkontrolujte svůj e-mail pro nastavení.", "passwordCreate": "Vytvořit heslo", "passwordCreateConfirm": "Potvrďte heslo", "accountSetupSubmit": "Odeslat instalační kód", "completeSetup": "Dokončit nastavení", "accountSetupSuccess": "Nastavení účtu dokončeno! Vítejte v Pangolinu!", "documentation": "Dokumentace", "saveAllSettings": "Uložit všechna nastavení", "saveResourceTargets": "Uložit cíle", "saveResourceHttp": "Uložit nastavení proxy", "saveProxyProtocol": "Uložit nastavení proxy protokolu", "settingsUpdated": "Nastavení aktualizováno", "settingsUpdatedDescription": "Nastavení úspěšně aktualizována", "settingsErrorUpdate": "Aktualizace nastavení se nezdařila", "settingsErrorUpdateDescription": "Došlo k chybě při aktualizaci nastavení", "sidebarCollapse": "Sbalit", "sidebarExpand": "Rozbalit", "productUpdateMoreInfo": "{noOfUpdates} další aktualizace", "productUpdateInfo": "Aktualizace {noOfUpdates}", "productUpdateWhatsNew": "Co je nového", "productUpdateTitle": "Aktualizace produktu", "productUpdateEmpty": "Žádné aktualizace", "dismissAll": "Odmítnout vše", "pangolinUpdateAvailable": "Dostupná aktualizace", "pangolinUpdateAvailableInfo": "Verze {version} je připravena k instalaci", "pangolinUpdateAvailableReleaseNotes": "Zobrazit poznámky k vydání", "newtUpdateAvailable": "Dostupná aktualizace", "newtUpdateAvailableInfo": "Je k dispozici nová verze Newt. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.", "domainPickerEnterDomain": "Doména", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Zadejte úplnou doménu zdroje pro zobrazení dostupných možností.", "domainPickerDescriptionSaas": "Zadejte celou doménu, subdoménu nebo jméno pro zobrazení dostupných možností", "domainPickerTabAll": "Vše", "domainPickerTabOrganization": "Organizace", "domainPickerTabProvided": "Poskytnuto", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Kontrola dostupnosti...", "domainPickerNoMatchingDomains": "Nebyly nalezeny žádné odpovídající domény. Zkuste jinou doménu nebo zkontrolujte nastavení domény organizace.", "domainPickerOrganizationDomains": "Domény organizace", "domainPickerProvidedDomains": "Poskytnuté domény", "domainPickerSubdomain": "Subdoména: {subdomain}", "domainPickerNamespace": "Jmenný prostor: {namespace}", "domainPickerShowMore": "Zobrazit více", "regionSelectorTitle": "Vybrat region", "regionSelectorInfo": "Výběr regionu nám pomáhá poskytovat lepší výkon pro vaši polohu. Nemusíte být ve stejném regionu jako váš server.", "regionSelectorPlaceholder": "Vyberte region", "regionSelectorComingSoon": "Již brzy", "billingLoadingSubscription": "Načítání odběru...", "billingFreeTier": "Volná úroveň", "billingWarningOverLimit": "Upozornění: Překročili jste jeden nebo více omezení používání. Vaše stránky se nepřipojí dokud nezměníte předplatné nebo neupravíte své používání.", "billingUsageLimitsOverview": "Přehled omezení použití", "billingMonitorUsage": "Sledujte vaše využití pomocí nastavených limitů. Pokud potřebujete zvýšit limity, kontaktujte nás prosím support@pangolin.net.", "billingDataUsage": "Využití dat", "billingSites": "Stránky", "billingUsers": "Uživatelé", "billingDomains": "Domény", "billingOrganizations": "Tělo", "billingRemoteExitNodes": "Vzdálené uzly", "billingNoLimitConfigured": "Žádný limit nenastaven", "billingEstimatedPeriod": "Odhadované období fakturace", "billingIncludedUsage": "Zahrnuto využití", "billingIncludedUsageDescription": "Využití zahrnované s aktuálním plánem předplatného", "billingFreeTierIncludedUsage": "Povolenky bezplatné úrovně využití", "billingIncluded": "zahrnuto", "billingEstimatedTotal": "Odhadovaný celkem:", "billingNotes": "Poznámky", "billingEstimateNote": "Toto je odhad založený na aktuálním využití.", "billingActualChargesMayVary": "Skutečné náklady se mohou lišit.", "billingBilledAtEnd": "Budete účtováni na konci fakturační doby.", "billingModifySubscription": "Upravit předplatné", "billingStartSubscription": "Začít předplatné", "billingRecurringCharge": "Opakované nabití", "billingManageSubscriptionSettings": "Správa nastavení předplatného a předvoleb", "billingNoActiveSubscription": "Nemáte aktivní předplatné. Začněte předplatné, abyste zvýšili omezení používání.", "billingFailedToLoadSubscription": "Nepodařilo se načíst odběr", "billingFailedToLoadUsage": "Nepodařilo se načíst využití", "billingFailedToGetCheckoutUrl": "Nepodařilo se získat adresu URL pokladny", "billingPleaseTryAgainLater": "Zkuste to prosím znovu později.", "billingCheckoutError": "Chyba pokladny", "billingFailedToGetPortalUrl": "Nepodařilo se získat URL portálu", "billingPortalError": "Chyba portálu", "billingDataUsageInfo": "Pokud jste připojeni k cloudu, jsou vám účtována všechna data přenášená prostřednictvím zabezpečených tunelů. To zahrnuje příchozí i odchozí provoz na všech vašich stránkách. Jakmile dosáhnete svého limitu, vaše stránky se odpojí, dokud neaktualizujete svůj tarif nebo nezmenšíte jeho používání. Data nejsou nabírána při používání uzlů.", "billingSInfo": "Kolik stránek můžete použít", "billingUsersInfo": "Kolik uživatelů můžete použít", "billingDomainInfo": "Kolik domén můžete použít", "billingRemoteExitNodesInfo": "Kolik vzdálených uzlů můžete použít", "billingLicenseKeys": "Licenční klíče", "billingLicenseKeysDescription": "Spravovat předplatné licenčního klíče", "billingLicenseSubscription": "Předplatné licence", "billingInactive": "Neaktivní", "billingLicenseItem": "Položka licence", "billingQuantity": "Množství", "billingTotal": "celkem", "billingModifyLicenses": "Upravit předplatné licence", "domainNotFound": "Doména nenalezena", "domainNotFoundDescription": "Tento dokument je zakázán, protože doména již neexistuje náš systém. Nastavte prosím novou doménu pro tento dokument.", "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", "securityKeyRegister": "Registrovat nový bezpečnostní klíč", "securityKeyList": "Vaše bezpečnostní klíče", "securityKeyNone": "Zatím nejsou registrovány žádné bezpečnostní klíče", "securityKeyNameRequired": "Název je povinný", "securityKeyRemove": "Odebrat", "securityKeyLastUsed": "Naposledy použito: {date}", "securityKeyNameLabel": "Název bezpečnostního klíče", "securityKeyRegisterSuccess": "Bezpečnostní klíč byl úspěšně zaregistrován", "securityKeyRegisterError": "Nepodařilo se zaregistrovat bezpečnostní klíč", "securityKeyRemoveSuccess": "Bezpečnostní klíč byl úspěšně odstraněn", "securityKeyRemoveError": "Odstranění bezpečnostního klíče se nezdařilo", "securityKeyLoadError": "Nepodařilo se načíst bezpečnostní klíče", "securityKeyLogin": "Použít bezpečnostní klíč", "securityKeyAuthError": "Ověření bezpečnostním klíčem se nezdařilo", "securityKeyRecommendation": "Registrujte záložní bezpečnostní klíč na jiném zařízení, abyste zajistili, že budete mít vždy přístup ke svému účtu.", "registering": "Registrace...", "securityKeyPrompt": "Ověřte svou identitu pomocí bezpečnostního klíče. Ujistěte se, že je Váš bezpečnostní klíč připojen a připraven.", "securityKeyBrowserNotSupported": "Váš prohlížeč nepodporuje bezpečnostní klíče. Použijte prosím moderní prohlížeč jako Chrome, Firefox nebo Safari.", "securityKeyPermissionDenied": "Prosím povolte přístup k bezpečnostnímu klíči, abyste mohli pokračovat v přihlašování.", "securityKeyRemovedTooQuickly": "Prosím udržujte svůj bezpečnostní klíč připojený do dokončení procesu přihlášení.", "securityKeyNotSupported": "Váš bezpečnostní klíč nemusí být kompatibilní. Zkuste jiný bezpečnostní klíč.", "securityKeyUnknownError": "Vyskytl se problém s použitím bezpečnostního klíče. Zkuste to prosím znovu.", "twoFactorRequired": "Pro registraci bezpečnostního klíče je nutné dvoufaktorové ověření.", "twoFactor": "Dvoufaktorové ověření", "twoFactorAuthentication": "Dvoufaktorové ověření", "twoFactorDescription": "Tato organizace vyžaduje dvoufaktorové ověření.", "enableTwoFactor": "Povolit dvoufaktorové ověření", "organizationSecurityPolicy": "Bezpečnostní politika organizace", "organizationSecurityPolicyDescription": "Tato organizace má bezpečnostní požadavky, které musí být splněny, než k ní budete mít přístup", "securityRequirements": "Bezpečnostní požadavky", "allRequirementsMet": "Všechny požadavky byly splněny", "completeRequirementsToContinue": "Pro pokračování v přístupu k této organizaci splněte níže uvedené požadavky", "youCanNowAccessOrganization": "Nyní můžete přistupovat k této organizaci", "reauthenticationRequired": "Délka relace", "reauthenticationDescription": "Tato organizace vyžaduje, abyste se přihlásili každých {maxDays} dní.", "reauthenticationDescriptionHours": "Tato organizace vyžaduje, abyste se přihlásili každých {maxHours} hodin.", "reauthenticateNow": "Přihlásit se znovu", "adminEnabled2FaOnYourAccount": "Váš správce povolil dvoufaktorové ověřování pro {email}. Chcete-li pokračovat, dokončete proces nastavení.", "securityKeyAdd": "Přidat bezpečnostní klíč", "securityKeyRegisterTitle": "Registrovat nový bezpečnostní klíč", "securityKeyRegisterDescription": "Připojte svůj bezpečnostní klíč a zadejte jméno pro jeho identifikaci", "securityKeyTwoFactorRequired": "Vyžadováno dvoufaktorové ověření", "securityKeyTwoFactorDescription": "Zadejte prosím váš dvoufaktorový ověřovací kód pro registraci bezpečnostního klíče", "securityKeyTwoFactorRemoveDescription": "Zadejte prosím váš dvoufaktorový ověřovací kód pro odstranění bezpečnostního klíče", "securityKeyTwoFactorCode": "Dvoufaktorový kód", "securityKeyRemoveTitle": "Odstranit bezpečnostní klíč", "securityKeyRemoveDescription": "Zadejte své heslo pro odstranění bezpečnostního klíče \"{name}\"", "securityKeyNoKeysRegistered": "Nebyly registrovány žádné bezpečnostní klíče", "securityKeyNoKeysDescription": "Přidejte bezpečnostní klíč pro zvýšení zabezpečení vašeho účtu", "createDomainRequired": "Doména je vyžadována", "createDomainAddDnsRecords": "Přidat DNS záznamy", "createDomainAddDnsRecordsDescription": "Přidejte následující DNS záznamy k poskytovateli domény pro dokončení nastavení.", "createDomainNsRecords": "NS záznamy", "createDomainRecord": "Nahrávat", "createDomainType": "Typ:", "createDomainName": "Jméno:", "createDomainValue": "Hodnota:", "createDomainCnameRecords": "Záznamy CNAME", "createDomainARecords": "Záznamy", "createDomainRecordNumber": "Nahrát {number}", "createDomainTxtRecords": "TXT záznamy", "createDomainSaveTheseRecords": "Uložit tyto záznamy", "createDomainSaveTheseRecordsDescription": "Ujistěte se, že chcete uložit tyto DNS záznamy, protože je znovu neuvidíte.", "createDomainDnsPropagation": "Šíření DNS", "createDomainDnsPropagationDescription": "Změna DNS může trvat nějakou dobu, než se šíří po internetu. To může trvat kdekoli od několika minut do 48 hodin v závislosti na poskytovateli DNS a nastavení TTL.", "resourcePortRequired": "Pro neHTTP zdroje je vyžadováno číslo portu", "resourcePortNotAllowed": "Číslo portu by nemělo být nastaveno pro HTTP zdroje", "billingPricingCalculatorLink": "Cenová kalkulačka", "billingYourPlan": "Váš plán", "billingViewOrModifyPlan": "Zobrazit nebo upravit aktuální tarif", "billingViewPlanDetails": "Zobrazit detaily plánu", "billingUsageAndLimits": "Limity a použití", "billingViewUsageAndLimits": "Zobrazit limity vašeho plánu a aktuální využití", "billingCurrentUsage": "Aktuální využití", "billingMaximumLimits": "Maximální limity", "billingRemoteNodes": "Vzdálené uzly", "billingUnlimited": "Bez omezení", "billingPaidLicenseKeys": "Placené licenční klíče", "billingManageLicenseSubscription": "Spravujte své předplatné za placené samohostované licenční klíče", "billingCurrentKeys": "Aktuální klíče", "billingModifyCurrentPlan": "Upravit aktuální tarif", "billingConfirmUpgrade": "Potvrdit aktualizaci", "billingConfirmDowngrade": "Potvrdit downgrade", "billingConfirmUpgradeDescription": "Chystáte se povýšit svůj tarif. Přečtěte si nové limity a ceny.", "billingConfirmDowngradeDescription": "Chystáte se snížit svůj tarif. Přečtěte si nové limity a ceny níže.", "billingPlanIncludes": "Plán zahrnuje", "billingProcessing": "Zpracovávám...", "billingConfirmUpgradeButton": "Potvrdit aktualizaci", "billingConfirmDowngradeButton": "Potvrdit downgrade", "billingLimitViolationWarning": "Využití překročilo limity nového plánu", "billingLimitViolationDescription": "Vaše současné využití překračuje meze tohoto plánu. Po ponížení budou všechny akce zakázány, dokud nesnížíte využití v rámci nových limitů. Přečtěte si prosím níže uvedené funkce překračující limity. Limity při porušení:", "billingFeatureLossWarning": "Upozornění na dostupnost funkce", "billingFeatureLossDescription": "Po pomenutí budou funkce v novém plánu automaticky zakázány. Některá nastavení a konfigurace mohou být ztraceny. Zkontrolujte cenovou matrici, abyste pochopili, které funkce již nebudou k dispozici.", "billingUsageExceedsLimit": "Aktuální využití ({current}) překračuje limit ({limit})", "billingPastDueTitle": "Poslední splatnost platby", "billingPastDueDescription": "Vaše platba je již splatná. Chcete-li pokračovat v používání aktuálních tarifů, aktualizujte prosím způsob platby. Pokud nebude vyřešeno, Vaše předplatné bude zrušeno a budete vráceno na úroveň zdarma.", "billingUnpaidTitle": "Předplatné nezaplaceno", "billingUnpaidDescription": "Vaše předplatné není zaplaceno a byli jste vráceni do bezplatné úrovně. Aktualizujte prosím svou platební metodu pro obnovení předplatného.", "billingIncompleteTitle": "Platba nedokončena", "billingIncompleteDescription": "Vaše platba je neúplná. Pro aktivaci předplatného prosím dokončete platební proces.", "billingIncompleteExpiredTitle": "Platba vypršela", "billingIncompleteExpiredDescription": "Vaše platba nebyla nikdy dokončena a vypršela. Byli jste vráceni na úroveň zdarma. Prosím, přihlašte se znovu pro obnovení přístupu k placeným funkcím.", "billingManageSubscription": "Spravujte své předplatné", "billingResolvePaymentIssue": "Vyřešte prosím problém s platbou před upgradem nebo upgradem", "signUpTerms": { "IAgreeToThe": "Souhlasím s", "termsOfService": "podmínky služby", "and": "a", "privacyPolicy": "zásady ochrany osobních údajů." }, "signUpMarketing": { "keepMeInTheLoop": "Udržujte mě ve smyčce s novinkami, aktualizacemi a novými funkcemi e-mailem." }, "siteRequired": "Stránka je povinná.", "olmTunnel": "Starý tunel", "olmTunnelDescription": "Použít Olm pro připojení klienta", "errorCreatingClient": "Chyba při vytváření klienta", "clientDefaultsNotFound": "Výchozí hodnoty klienta nebyly nalezeny", "createClient": "Vytvořit klienta", "createClientDescription": "Vytvořte nového klienta pro přístup k soukromým zdrojům", "seeAllClients": "Zobrazit všechny klienty", "clientInformation": "Informace o klientovi", "clientNamePlaceholder": "Název klienta", "address": "Adresa", "subnetPlaceholder": "Podsíť", "addressDescription": "Interní adresa klienta. Musí spadat do podsítě organizace.", "selectSites": "Vyberte stránky", "sitesDescription": "Klient bude mít připojení k vybraným webům", "clientInstallOlm": "Nainstalovat Olm", "clientInstallOlmDescription": "Stáhněte si Olm běžící ve vašem systému", "clientOlmCredentials": "Pověření", "clientOlmCredentialsDescription": "Tímto způsobem bude klient autentizovat se serverem", "olmEndpoint": "Endpoint", "olmId": "ID", "olmSecretKey": "Tajný klíč", "clientCredentialsSave": "Uložit pověření", "clientCredentialsSaveDescription": "Toto nastavení uvidíte pouze jednou. Ujistěte se, že jej zkopírujete na bezpečné místo.", "generalSettingsDescription": "Konfigurace obecných nastavení pro tohoto klienta", "clientUpdated": "Klient byl aktualizován", "clientUpdatedDescription": "Klient byl aktualizován.", "clientUpdateFailed": "Nepodařilo se aktualizovat klienta", "clientUpdateError": "Došlo k chybě při aktualizaci klienta.", "sitesFetchFailed": "Nepodařilo se načíst stránky", "sitesFetchError": "Došlo k chybě při načítání stránek.", "olmErrorFetchReleases": "Při načítání vydání Olm došlo k chybě.", "olmErrorFetchLatest": "Došlo k chybě při načítání nejnovější verze Olm.", "enterCidrRange": "Zadejte rozsah CIDR", "resourceEnableProxy": "Povolit veřejné proxy", "resourceEnableProxyDescription": "Povolit veřejné proxying pro tento zdroj. To umožňuje přístup ke zdrojům mimo síť prostřednictvím cloudu na otevřeném portu. Vyžaduje nastavení Traefik.", "externalProxyEnabled": "Externí proxy povolen", "addNewTarget": "Add New Target", "targetsList": "Seznam cílů", "advancedMode": "Pokročilý režim", "advancedSettings": "Pokročilá nastavení", "targetErrorDuplicateTargetFound": "Byl nalezen duplicitní cíl", "healthCheckHealthy": "Zdravé", "healthCheckUnhealthy": "Nezdravé", "healthCheckUnknown": "Neznámý", "healthCheck": "Kontrola stavu", "configureHealthCheck": "Konfigurace kontroly stavu", "configureHealthCheckDescription": "Nastavit sledování zdravotního stavu pro {target}", "enableHealthChecks": "Povolit kontrolu stavu", "enableHealthChecksDescription": "Sledujte zdraví tohoto cíle. V případě potřeby můžete sledovat jiný cílový bod, než je cíl.", "healthScheme": "Způsob", "healthSelectScheme": "Vybrat metodu", "healthCheckPortInvalid": "Přístav kontroly stavu musí být mezi 1 a 65535", "healthCheckPath": "Cesta", "healthHostname": "IP / Hostitel", "healthPort": "Přístav", "healthCheckPathDescription": "Cesta ke kontrole zdravotního stavu.", "healthyIntervalSeconds": "Zdravý interval (sek)", "unhealthyIntervalSeconds": "Nezdravý interval (sek)", "IntervalSeconds": "Interval zdraví", "timeoutSeconds": "Časový limit (sek)", "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": "Čárkami oddělený seznam příkazů, které může uživatel spouštět s sudo.", "sshCreateHomeDir": "Vytvořit domovský adresář", "sshUnixGroups": "Unixové skupiny", "sshUnixGroupsDescription": "Čárkou oddělené skupiny Unix 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é.", "customHeaders": "Vlastní záhlaví", "customHeadersDescription": "Záhlaví oddělená nová řádka: hodnota", "headersValidationError": "Headers must be in the format: Header-Name: value.", "saveHealthCheck": "Uložit kontrolu stavu", "healthCheckSaved": "Kontrola stavu uložena", "healthCheckSavedDescription": "Nastavení kontroly stavu bylo úspěšně uloženo", "healthCheckError": "Chyba kontroly stavu", "healthCheckErrorDescription": "Došlo k chybě při ukládání konfigurace kontroly stavu", "healthCheckPathRequired": "Je vyžadována cesta kontroly stavu", "healthCheckMethodRequired": "HTTP metoda je povinná", "healthCheckIntervalMin": "Interval kontroly musí být nejméně 5 sekund", "healthCheckTimeoutMin": "Časový limit musí být nejméně 1 sekunda", "healthCheckRetryMin": "Pokusy opakovat musí být alespoň 1", "httpMethod": "HTTP metoda", "selectHttpMethod": "Vyberte HTTP metodu", "domainPickerSubdomainLabel": "Subdoména", "domainPickerBaseDomainLabel": "Základní doména", "domainPickerSearchDomains": "Hledat domény...", "domainPickerNoDomainsFound": "Nebyly nalezeny žádné domény", "domainPickerLoadingDomains": "Načítám domény...", "domainPickerSelectBaseDomain": "Vyberte základní doménu...", "domainPickerNotAvailableForCname": "Není k dispozici pro domény CNAME", "domainPickerEnterSubdomainOrLeaveBlank": "Zadejte subdoménu nebo ponechte prázdné pro použití základní domény.", "domainPickerEnterSubdomainToSearch": "Zadejte subdoménu pro hledání a výběr z dostupných domén zdarma.", "domainPickerFreeDomains": "Volné domény", "domainPickerSearchForAvailableDomains": "Hledat dostupné domény", "domainPickerNotWorkSelfHosted": "Poznámka: Poskytnuté domény nejsou momentálně k dispozici pro vlastní hostované instance.", "resourceDomain": "Doména", "resourceEditDomain": "Upravit doménu", "siteName": "Název webu", "proxyPort": "Přístav", "resourcesTableProxyResources": "Veřejnost", "resourcesTableClientResources": "Soukromé", "resourcesTableNoProxyResourcesFound": "Nebyly nalezeny žádné zdroje proxy", "resourcesTableNoInternalResourcesFound": "Nebyly nalezeny žádné vnitřní zdroje.", "resourcesTableDestination": "Místo určení", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Adresa aliasu", "resourcesTableAliasAddressInfo": "Tato adresa je součástí subsítě veřejných služeb organizace. Používá se k řešení záznamů aliasů pomocí interního rozlišení DNS.", "resourcesTableClients": "Klienti", "resourcesTableAndOnlyAccessibleInternally": "a jsou interně přístupné pouze v případě, že jsou propojeni s klientem.", "resourcesTableNoTargets": "Žádné cíle", "resourcesTableHealthy": "Zdravé", "resourcesTableDegraded": "Rozklad", "resourcesTableOffline": "Offline", "resourcesTableUnknown": "Neznámý", "resourcesTableNotMonitored": "Není sledováno", "editInternalResourceDialogEditClientResource": "Upravit soukromý dokument", "editInternalResourceDialogUpdateResourceProperties": "Aktualizovat konfiguraci zdroje a ovládací prvky přístupu pro {resourceName}", "editInternalResourceDialogResourceProperties": "Vlastnosti zdroje", "editInternalResourceDialogName": "Jméno", "editInternalResourceDialogProtocol": "Protokol", "editInternalResourceDialogSitePort": "Port webu", "editInternalResourceDialogTargetConfiguration": "Target Configuration", "editInternalResourceDialogCancel": "Zrušit", "editInternalResourceDialogSaveResource": "Uložit dokument", "editInternalResourceDialogSuccess": "Úspěšně", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Interní zdroj byl úspěšně aktualizován", "editInternalResourceDialogError": "Chyba", "editInternalResourceDialogFailedToUpdateInternalResource": "Aktualizace interního zdroje se nezdařila", "editInternalResourceDialogNameRequired": "Název je povinný", "editInternalResourceDialogNameMaxLength": "Název musí mít méně než 255 znaků", "editInternalResourceDialogProxyPortMin": "Port proxy serveru musí být alespoň 1", "editInternalResourceDialogProxyPortMax": "Port proxy serveru musí být menší než 65536", "editInternalResourceDialogInvalidIPAddressFormat": "Neplatný formát IP adresy", "editInternalResourceDialogDestinationPortMin": "Cílový přístav musí být alespoň 1", "editInternalResourceDialogDestinationPortMax": "Cílový přístav musí být nižší než 65536", "editInternalResourceDialogPortModeRequired": "Protokol, proxy port a cílový port jsou vyžadovány pro režim portu", "editInternalResourceDialogMode": "Režim", "editInternalResourceDialogModePort": "Přístav", "editInternalResourceDialogModeHost": "Hostitel", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Místo určení", "editInternalResourceDialogDestinationHostDescription": "IP adresa nebo název hostitele zdroje v síti webu.", "editInternalResourceDialogDestinationIPDescription": "IP nebo název hostitele zdroje v síti webu.", "editInternalResourceDialogDestinationCidrDescription": "Rozsah zdrojů CIDR v síti webu.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Volitelný interní DNS alias pro tento dokument.", "createInternalResourceDialogNoSitesAvailable": "Nejsou k dispozici žádné weby", "createInternalResourceDialogNoSitesAvailableDescription": "Musíte mít alespoň jeden Newt web s podsítí nakonfigurovanou pro vytvoření vnitřních zdrojů.", "createInternalResourceDialogClose": "Zavřít", "createInternalResourceDialogCreateClientResource": "Vytvořit soukromý zdroj", "createInternalResourceDialogCreateClientResourceDescription": "Vytvořte nový zdroj, který bude přístupný pouze klientům připojeným k organizaci", "createInternalResourceDialogResourceProperties": "Vlastnosti zdroje", "createInternalResourceDialogName": "Jméno", "createInternalResourceDialogSite": "Lokalita", "selectSite": "Vybrat lokalitu...", "noSitesFound": "Nebyly nalezeny žádné lokality.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Port webu", "createInternalResourceDialogSitePortDescription": "Použijte tento port pro přístup ke zdroji na webu při připojení s klientem.", "createInternalResourceDialogTargetConfiguration": "Target Configuration", "createInternalResourceDialogDestinationIPDescription": "IP nebo název hostitele zdroje v síti webu.", "createInternalResourceDialogDestinationPortDescription": "Přístav na cílové IP adrese, kde je zdroj dostupný.", "createInternalResourceDialogCancel": "Zrušit", "createInternalResourceDialogCreateResource": "Vytvořit zdroj", "createInternalResourceDialogSuccess": "Úspěšně", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Interní zdroj byl úspěšně vytvořen", "createInternalResourceDialogError": "Chyba", "createInternalResourceDialogFailedToCreateInternalResource": "Nepodařilo se vytvořit interní zdroj", "createInternalResourceDialogNameRequired": "Název je povinný", "createInternalResourceDialogNameMaxLength": "Název musí mít méně než 255 znaků", "createInternalResourceDialogPleaseSelectSite": "Vyberte prosím web", "createInternalResourceDialogProxyPortMin": "Port proxy serveru musí být alespoň 1", "createInternalResourceDialogProxyPortMax": "Port proxy serveru musí být menší než 65536", "createInternalResourceDialogInvalidIPAddressFormat": "Neplatný formát IP adresy", "createInternalResourceDialogDestinationPortMin": "Cílový přístav musí být alespoň 1", "createInternalResourceDialogDestinationPortMax": "Cílový přístav musí být nižší než 65536", "createInternalResourceDialogPortModeRequired": "Protokol, proxy port a cílový port jsou vyžadovány pro režim portu", "createInternalResourceDialogMode": "Režim", "createInternalResourceDialogModePort": "Přístav", "createInternalResourceDialogModeHost": "Hostitel", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Místo určení", "createInternalResourceDialogDestinationHostDescription": "IP adresa nebo název hostitele zdroje v síti webu.", "createInternalResourceDialogDestinationCidrDescription": "Rozsah zdrojů CIDR v síti webu.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Volitelný interní DNS alias pro tento dokument.", "siteConfiguration": "Konfigurace", "siteAcceptClientConnections": "Přijmout připojení klienta", "siteAcceptClientConnectionsDescription": "Povolit uživatelským zařízením a klientům přístup ke zdrojům na tomto webu. To lze později změnit.", "siteAddress": "Adresa webu (Advanced)", "siteAddressDescription": "Interní adresa webu. Musí spadat do podsítě organizace.", "siteNameDescription": "Zobrazovaný název stránky, který lze později změnit.", "autoLoginExternalIdp": "Automatické přihlášení pomocí externího IDP", "autoLoginExternalIdpDescription": "Ihned přesměrujte uživatele na externího poskytovatele identity pro autentifikaci.", "selectIdp": "Vybrat IDP", "selectIdpPlaceholder": "Vyberte IDP...", "selectIdpRequired": "Prosím vyberte IDP, když je povoleno automatické přihlášení.", "autoLoginTitle": "Přesměrování", "autoLoginDescription": "Přesměrování k externímu poskytovateli identity pro ověření.", "autoLoginProcessing": "Příprava ověřování...", "autoLoginRedirecting": "Přesměrování k přihlášení...", "autoLoginError": "Automatická chyba přihlášení", "autoLoginErrorNoRedirectUrl": "Od poskytovatele identity nebyla obdržena žádná adresa URL.", "autoLoginErrorGeneratingUrl": "Nepodařilo se vygenerovat ověřovací URL.", "remoteExitNodeManageRemoteExitNodes": "Vzdálené uzly", "remoteExitNodeDescription": "Hostujte vlastní vzdálený relační a proxy server uzly", "remoteExitNodes": "Uzly", "searchRemoteExitNodes": "Hledat uzly...", "remoteExitNodeAdd": "Přidat uzel", "remoteExitNodeErrorDelete": "Chyba při odstraňování uzlu", "remoteExitNodeQuestionRemove": "Jste si jisti, že chcete odstranit uzel z organizace?", "remoteExitNodeMessageRemove": "Po odstranění uzel již nebude přístupný.", "remoteExitNodeConfirmDelete": "Potvrdit odstranění uzlu", "remoteExitNodeDelete": "Odstranit uzel", "sidebarRemoteExitNodes": "Vzdálené uzly", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Tajný klíč", "remoteExitNodeCreate": { "title": "Vytvořit vzdálený uzel", "description": "Vytvořte nový vlastní hostovaný vzdálený relační a proxy server uzel", "viewAllButton": "Zobrazit všechny uzly", "strategy": { "title": "Strategie tvorby", "description": "Vyberte, jak chcete vytvořit vzdálený uzel", "adopt": { "title": "Přijmout uzel", "description": "Zvolte tuto možnost, pokud již máte přihlašovací údaje k uzlu." }, "generate": { "title": "Generovat klíče", "description": "Vyberte tuto možnost, pokud chcete vygenerovat nové klíče pro uzel." } }, "adopt": { "title": "Přijmout existující uzel", "description": "Zadejte přihlašovací údaje existujícího uzlu, který chcete přijmout", "nodeIdLabel": "ID uzlu", "nodeIdDescription": "ID existujícího uzlu, který chcete přijmout", "secretLabel": "Tajný klíč", "secretDescription": "Tajný klíč existujícího uzlu", "submitButton": "Přijmout uzel" }, "generate": { "title": "Vygenerovaná pověření", "description": "Použijte tyto generované přihlašovací údaje pro nastavení uzlu", "nodeIdTitle": "ID uzlu", "secretTitle": "Tajný klíč", "saveCredentialsTitle": "Přidat přihlašovací údaje do konfigurace", "saveCredentialsDescription": "Přidejte tyto přihlašovací údaje do vlastního konfiguračního souboru Pangolin uzlu pro dokončení připojení.", "submitButton": "Vytvořit uzel" }, "validation": { "adoptRequired": "ID uzlu a tajný klíč jsou vyžadovány při přijetí existujícího uzlu" }, "errors": { "loadDefaultsFailed": "Nepodařilo se načíst výchozí hodnoty", "defaultsNotLoaded": "Výchozí hodnoty nebyly načteny", "createFailed": "Nepodařilo se vytvořit uzel" }, "success": { "created": "Uzel byl úspěšně vytvořen" } }, "remoteExitNodeSelection": "Výběr uzlu", "remoteExitNodeSelectionDescription": "Vyberte uzel pro směrování provozu přes tuto lokální stránku", "remoteExitNodeRequired": "Pro lokální stránky musí být vybrán uzel", "noRemoteExitNodesAvailable": "Nejsou k dispozici žádné uzly", "noRemoteExitNodesAvailableDescription": "Pro tuto organizaci nejsou k dispozici žádné uzly. Nejprve vytvořte uzel pro použití lokálních stránek.", "exitNode": "Ukončit uzel", "country": "L 343, 22.12.2009, s. 1).", "rulesMatchCountry": "Aktuálně založené na zdrojové IP adrese", "managedSelfHosted": { "title": "Spravované vlastní hostování", "description": "Spolehlivější a nízko udržovaný Pangolinův server s dalšími zvony a bičkami", "introTitle": "Spravovaný Pangolin", "introDescription": "je možnost nasazení určená pro lidi, kteří chtějí jednoduchost a spolehlivost při zachování soukromých a samoobslužných dat.", "introDetail": "Pomocí této volby stále provozujete vlastní uzel Pangolin — tunely, SSL terminály a provoz všech pobytů na vašem serveru. Rozdíl spočívá v tom, že řízení a monitorování se řeší prostřednictvím našeho cloudového panelu, který odemkne řadu výhod:", "benefitSimplerOperations": { "title": "Jednoduchý provoz", "description": "Není třeba spouštět svůj vlastní poštovní server nebo nastavit komplexní upozornění. Ze schránky dostanete upozornění na zdravotní kontrolu a výpadek." }, "benefitAutomaticUpdates": { "title": "Automatické aktualizace", "description": "Nástěnka cloudu se rychle vyvíjí, takže dostanete nové funkce a opravy chyb, aniž byste museli vždy ručně stahovat nové kontejnery." }, "benefitLessMaintenance": { "title": "Méně údržby", "description": "Žádná migrace do databáze, zálohování nebo další infrastruktura pro správu. Zabýváme se tím v cloudu." }, "benefitCloudFailover": { "title": "Selhání cloudu", "description": "Pokud váš uzel klesne, vaše tunely mohou dočasně selhat na naše body přítomnosti v cloudu, dokud jej nevrátíte zpět online." }, "benefitHighAvailability": { "title": "Vysoká dostupnost (PoP)", "description": "Můžete také připojit více uzlů k vašemu účtu pro nadbytečnost a lepší výkon." }, "benefitFutureEnhancements": { "title": "Budoucí vylepšení", "description": "Plánujeme přidat více analytických, varovných a manažerských nástrojů, aby bylo vaše nasazení ještě robustnější." }, "docsAlert": { "text": "Další informace o možnostech Managed Self-Hosted v našem", "documentation": "dokumentace" }, "convertButton": "Převést tento uzel na spravovaný vlastní hostitel" }, "internationaldomaindetected": "Zjištěna mezinárodní doména", "willbestoredas": "Bude uloženo jako:", "roleMappingDescription": "Určete, jak jsou role přiřazeny uživatelům, když se přihlásí, když je povoleno automatické poskytnutí služby.", "selectRole": "Vyberte roli", "roleMappingExpression": "Výraz", "selectRolePlaceholder": "Vyberte roli", "selectRoleDescription": "Vyberte roli pro přiřazení všem uživatelům od tohoto poskytovatele identity", "roleMappingExpressionDescription": "Zadejte výraz JMESPath pro získání informací o roli z ID token", "idpTenantIdRequired": "ID nájemce je povinné", "invalidValue": "Neplatná hodnota", "idpTypeLabel": "Typ poskytovatele identity", "roleMappingExpressionPlaceholder": "např. obsahuje(skupiny, 'admin') && 'Admin' || 'Member'", "idpGoogleConfiguration": "Konfigurace Google", "idpGoogleConfigurationDescription": "Konfigurace přihlašovacích údajů Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Tajný klíč klienta Google OAuth2", "idpAzureConfiguration": "Nastavení Azure Entra ID", "idpAzureConfigurationDescription": "Konfigurace Azure Entra ID OAuth2", "idpTenantId": "ID tenanta", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "ID nájemce Azura (nalezeno v přehledu Azure Active Directory - Azure Active Directory - přehled )", "idpAzureClientIdDescription": "ID klienta pro registraci aplikace Azure", "idpAzureClientSecretDescription": "Tajný klíč registrace aplikace Azure", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Konfigurace Google", "idpAzureConfigurationTitle": "Nastavení Azure Entra ID", "idpTenantIdLabel": "ID tenanta", "idpAzureClientIdDescription2": "ID klienta pro registraci aplikace Azure", "idpAzureClientSecretDescription2": "Tajný klíč registrace aplikace Azure", "idpGoogleDescription": "Poskytovatel Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Podsíť", "subnetDescription": "Podsíť pro konfiguraci sítě této organizace.", "customDomain": "Vlastní doména", "authPage": "Autentizační stránky", "authPageDescription": "Nastavte vlastní doménu pro autentizační stránky organizace", "authPageDomain": "Doména ověření stránky", "authPageBranding": "Vlastní branding", "authPageBrandingDescription": "Nastavte branding, který se objeví na autentizačních stránkách této organizace", "authPageBrandingUpdated": "Branding na autentizační stránce úspěšně aktualizován", "authPageBrandingRemoved": "Branding na autentizační stránce úspěšně odstraněn", "authPageBrandingRemoveTitle": "Odstranit branding autentizační stránky", "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)", "brandingOrgTitle": "Název pro autentizační stránku organizace", "brandingOrgDescription": "{orgName} bude nahrazeno názvem organizace", "brandingOrgSubtitle": "Podtitul pro autentizační stránku organizace", "brandingResourceTitle": "Název pro autentizační stránku prostředku", "brandingResourceSubtitle": "Podtitul pro autentizační stránku prostředku", "brandingResourceDescription": "{resourceName} bude nahrazeno názvem organizace", "saveAuthPageDomain": "Uložit doménu", "saveAuthPageBranding": "Uložit branding", "removeAuthPageBranding": "Odstranit branding", "noDomainSet": "Není nastavena žádná doména", "changeDomain": "Změnit doménu", "selectDomain": "Vybrat doménu", "restartCertificate": "Restartovat certifikát", "editAuthPageDomain": "Upravit doménu autentizační stránky", "setAuthPageDomain": "Nastavit doménu autentické stránky", "failedToFetchCertificate": "Nepodařilo se načíst certifikát", "failedToRestartCertificate": "Restartování certifikátu se nezdařilo", "addDomainToEnableCustomAuthPages": "Uživatelé budou schopni přistupovat k přihlašovací stránce organizace a dokončit autentifikaci prostředků použitím této domény.", "selectDomainForOrgAuthPage": "Vyberte doménu pro ověřovací stránku organizace", "domainPickerProvidedDomain": "Poskytnutá doména", "domainPickerFreeProvidedDomain": "Zdarma poskytnutá doména", "domainPickerVerified": "Ověřeno", "domainPickerUnverified": "Neověřeno", "domainPickerInvalidSubdomainStructure": "Tato subdoména obsahuje neplatné znaky nebo strukturu. Bude automaticky sanitována při uložení.", "domainPickerError": "Chyba", "domainPickerErrorLoadDomains": "Nepodařilo se načíst domény organizace", "domainPickerErrorCheckAvailability": "Kontrola dostupnosti domény se nezdařila", "domainPickerInvalidSubdomain": "Neplatná subdoména", "domainPickerInvalidSubdomainRemoved": "Vstup \"{sub}\" byl odstraněn, protože není platný.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" nemohl být platný pro {domain}.", "domainPickerSubdomainSanitized": "Upravená subdoména", "domainPickerSubdomainCorrected": "\"{sub}\" bylo opraveno na \"{sanitized}\"", "orgAuthSignInTitle": "Přihlášení do organizace", "orgAuthChooseIdpDescription": "Chcete-li pokračovat, vyberte svého poskytovatele identity", "orgAuthNoIdpConfigured": "Tato organizace nemá nakonfigurovány žádné poskytovatele identity. Místo toho se můžete přihlásit s vaší Pangolinovou identitou.", "orgAuthSignInWithPangolin": "Přihlásit se pomocí Pangolinu", "orgAuthSignInToOrg": "Přihlásit se do organizace", "orgAuthSelectOrgTitle": "Přihlášení do organizace", "orgAuthSelectOrgDescription": "Zadejte ID vaší organizace pro pokračování", "orgAuthOrgIdPlaceholder": "vaše-organizace", "orgAuthOrgIdHelp": "Zadejte jedinečný identifikátor vaší organizace", "orgAuthSelectOrgHelp": "Po zadání ID vaší organizace budete přesměrováni na přihlašovací stránku vaší organizace, kde můžete použít SSO nebo přihlašovací údaje vaší organizace.", "orgAuthRememberOrgId": "Zapamatujte si toto ID organizace", "orgAuthBackToSignIn": "Zpět ke standardnímu přihlášení", "orgAuthNoAccount": "Nemáte účet?", "subscriptionRequiredToUse": "Pro použití této funkce je vyžadováno předplatné.", "mustUpgradeToUse": "Pro použití této funkce musíte aktualizovat své předplatné.", "subscriptionRequiredTierToUse": "Tato funkce vyžaduje {tier} nebo vyšší.", "upgradeToTierToUse": "Pro použití této funkce upgradujte na {tier} nebo vyšší.", "subscriptionTierTier1": "Domů", "subscriptionTierTier2": "Tým", "subscriptionTierTier3": "Podniky", "subscriptionTierEnterprise": "Podniky", "idpDisabled": "Poskytovatelé identit jsou zakázáni.", "orgAuthPageDisabled": "Ověřovací stránka organizace je zakázána.", "domainRestartedDescription": "Ověření domény bylo úspěšně restartováno", "resourceAddEntrypointsEditFile": "Upravit soubor: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Upravit soubor: docker-compose.yml", "emailVerificationRequired": "Je vyžadováno ověření e-mailu. Přihlaste se znovu pomocí {dashboardUrl}/auth/login dokončete tento krok. Poté se vraťte zde.", "twoFactorSetupRequired": "Je vyžadováno nastavení dvoufaktorového ověřování. Přihlaste se znovu pomocí {dashboardUrl}/autentizace/přihlášení dokončí tento krok. Poté se vraťte zde.", "additionalSecurityRequired": "Vyžadováno další zabezpečení", "organizationRequiresAdditionalSteps": "Tato organizace vyžaduje další bezpečnostní kroky, než budete moci přistupovat ke zdrojům.", "completeTheseSteps": "Dokončete tyto kroky", "enableTwoFactorAuthentication": "Povolit dvoufaktorové ověření", "completeSecuritySteps": "Dokončit bezpečnostní kroky", "securitySettings": "Nastavení zabezpečení", "dangerSection": "Nebezpečná zóna", "dangerSectionDescription": "Trvale smazat všechna data spojená s touto organizací", "securitySettingsDescription": "Konfigurace bezpečnostních pravidel organizace", "requireTwoFactorForAllUsers": "Vyžadovat dvoufaktorové ověření pro všechny uživatele", "requireTwoFactorDescription": "Pokud je povoleno, všichni interní uživatelé v této organizaci musí mít dvoufaktorové ověření povoleno pro přístup k organizaci.", "requireTwoFactorDisabledDescription": "Tato funkce vyžaduje platnou licenci (Enterprise) nebo aktivní předplatné (SaaS)", "requireTwoFactorCannotEnableDescription": "Před vynucením dvoufaktorového ověření vašeho účtu musíte povolit dvoufaktorové ověření pro všechny uživatele", "maxSessionLength": "Maximální délka relace", "maxSessionLengthDescription": "Nastavte maximální dobu trvání relace uživatele. Po této době se uživatelé budou muset znovu přihlásit.", "maxSessionLengthDisabledDescription": "Tato funkce vyžaduje platnou licenci (Enterprise) nebo aktivní předplatné (SaaS)", "selectSessionLength": "Vyberte délku relace", "unenforced": "Nevynucené", "1Hour": "1 hodina", "3Hours": "3 hodiny", "6Hours": "6 hodin", "12Hours": "12 hodin", "1DaySession": "1 den", "3Days": "3 dny", "7Days": "7 dní", "14Days": "14 dní", "30DaysSession": "30 dní", "90DaysSession": "90 dní", "180DaysSession": "180 dní", "passwordExpiryDays": "Platnost hesla", "editPasswordExpiryDescription": "Nastavte počet dní před tím, než jsou uživatelé povinni změnit své heslo.", "selectPasswordExpiry": "Vyberte vypršení platnosti hesla", "30Days": "30 dní", "1Day": "1 den", "60Days": "60 dní", "90Days": "90 dní", "180Days": "180 dní", "1Year": "1 rok", "subscriptionBadge": "Vyžadováno předplatné", "securityPolicyChangeWarning": "Upozornění na změnu bezpečnostní politiky", "securityPolicyChangeDescription": "Chystáte se změnit nastavení bezpečnostních pravidel. Po uložení bude možná nutné znovu ověřit, abyste dodrželi tyto aktualizace přístupových práv. Všichni uživatelé, kteří nevyhovují, se také budou muset znovu přihlásit.", "securityPolicyChangeConfirmMessage": "Potvrzuji", "securityPolicyChangeWarningText": "Toto ovlivní všechny uživatele v organizaci", "authPageErrorUpdateMessage": "Při aktualizaci nastavení autentizační stránky došlo k chybě", "authPageErrorUpdate": "Nelze aktualizovat ověřovací stránku", "authPageDomainUpdated": "Doména na autentizační stránce úspěšně aktualizována", "healthCheckNotAvailable": "Místní", "rewritePath": "Přepsat cestu", "rewritePathDescription": "Volitelně přepište cestu před odesláním na cíl.", "continueToApplication": "Pokračovat v aplikaci", "checkingInvite": "Kontrola pozvánky", "setResourceHeaderAuth": "setResourceHeaderAuth", "resourceHeaderAuthRemove": "Odstranit Autentizaci Záhlaví", "resourceHeaderAuthRemoveDescription": "Úspěšně odstraněna autentizace záhlaví.", "resourceErrorHeaderAuthRemove": "Nepodařilo se odstranit Autentizaci Záhlaví", "resourceErrorHeaderAuthRemoveDescription": "Nepodařilo se odstranit autentizaci záhlaví ze zdroje.", "resourceHeaderAuthProtectionEnabled": "Ověřování pomocí hlaviček zapnuto", "resourceHeaderAuthProtectionDisabled": "Ověřování pomocí hlaviček vypnuto", "headerAuthRemove": "Odstranit ověřování pomocí hlaviček", "headerAuthAdd": "Přidat ověřování pomocí hlaviček", "resourceErrorHeaderAuthSetup": "Nepodařilo se nastavit Autentizaci Záhlaví", "resourceErrorHeaderAuthSetupDescription": "Nepodařilo se nastavit autentizaci záhlaví ze zdroje.", "resourceHeaderAuthSetup": "Úspěšně nastavena Autentizace Záhlaví", "resourceHeaderAuthSetupDescription": "Autentizace záhlaví byla úspěšně nastavena.", "resourceHeaderAuthSetupTitle": "Nastavit Autentizaci Záhlaví", "resourceHeaderAuthSetupTitleDescription": "Nastavte přihlašovací údaje basic auth (uživatelské jméno a heslo) abyste tento zdroj chránili ověřováním pomocí HTTP hlaviček. Pro přístup použijte adresu ve formátu https://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Nastavit Autentizaci Záhlaví", "actionSetResourceHeaderAuth": "Nastavit Autentizaci Záhlaví", "enterpriseEdition": "Podniková edice", "unlicensed": "Nelicencováno", "beta": "Beta", "manageUserDevices": "Uživatelská zařízení", "manageUserDevicesDescription": "Zobrazit a spravovat zařízení, která používají uživatelé k soukromím připojení k zdrojům", "downloadClientBannerTitle": "Stáhnout klienta Pangolinu", "downloadClientBannerDescription": "Stáhnout klienta Pangolinu do vašeho systému pro připojení k síti Pangolin a zajištění soukromého přístupu k prostředkům.", "manageMachineClients": "Správa automatických klientů", "manageMachineClientsDescription": "Vytvořte a spravujte klienty, které servery a systémy používají k soukromím připojování k zdrojům", "machineClientsBannerTitle": "Servery & Automatizované systémy", "machineClientsBannerDescription": "Klientské stroje jsou určeny pro servery a automatizované systémy, které nejsou přiřazeny k žádnému specifickému uživateli. Autentizují se pomocí ID a tajemství, a mohou běžet s Pangolin CLI, Olm CLI nebo Olm jako kontejner.", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Olm kontejner", "clientsTableUserClients": "Uživatel", "clientsTableMachineClients": "Stroj", "licenseTableValidUntil": "Platná do", "saasLicenseKeysSettingsTitle": "Podniková licence", "saasLicenseKeysSettingsDescription": "Vygenerovat a spravovat podnikové licence pro instance Pangolin na vlastním hostingu", "sidebarEnterpriseLicenses": "Licence", "generateLicenseKey": "Vygenerovat licenční klíč", "generateLicenseKeyForm": { "validation": { "emailRequired": "Prosím zadejte platnou emailovou adresu", "useCaseTypeRequired": "Vyberte prosím typ použití", "firstNameRequired": "Zadejte křestní jméno", "lastNameRequired": "Zadejte příjmení", "primaryUseRequired": "Popište prosím účel použití", "jobTitleRequiredBusiness": "Pro komerční využití zadejte název pracovní pozice", "industryRequiredBusiness": "Pro komerční využití zadejte odvětví společnosti", "stateProvinceRegionRequired": "Zadejte stát/provincii/oblast", "postalZipCodeRequired": "Zadejte PSČ", "companyNameRequiredBusiness": "Pro komerční využití zadejte název společnosti", "countryOfResidenceRequiredBusiness": "Pro komerční využití zadejte zemi sídla společnosti", "countryRequiredPersonal": "Pro osobní využití zadejte zemi", "agreeToTermsRequired": "Musíte souhlasit s podmínkami", "complianceConfirmationRequired": "Musíte potvrdit dodržování Fossorial Commercial Licence" }, "useCaseOptions": { "personal": { "title": "Osobní využití", "description": "Pro osobní nekomerční využití jako je vzdělávání, osobní projekty nebo experimentování." }, "business": { "title": "Podnikové využití", "description": "Pro použití v organizacích, společnostech nebo při činnostech generujících zisk." } }, "steps": { "emailLicenseType": { "title": "Email a typ licence", "description": "Zadejte svůj email a vyberte typ licence" }, "personalInformation": { "title": "Osobní údaje", "description": "Povězte nám něco o sobě" }, "contactInformation": { "title": "Kontaktní údaje", "description": "Vaše kontaktní údaje" }, "termsGenerate": { "title": "Podmínky a vygenerovat", "description": "Zkontrolujte a přijměte podmínky pro vygenerování vaší licence" } }, "alerts": { "commercialUseDisclosure": { "title": "Poskytnutí využití", "description": "Vyberte si typ licence který odráží způsob využití. Osobní licence dovoluje používat aplikaci pro osobní využití, nekomerční využití nebo v malých organizacích do ročního obratu pod 100 000 USD. Jakékoliv jiné využití včetně využití pro podnikání, organizace nebo jiná prostředí generující zisk vyžaduje platnou podnikovou licenci a platbu licenčních poplatků. Všichni uživatelé, ať už osobních nebo podnikových licencí, musí souhlasit s Fossorial Commercial License Terms." }, "trialPeriodInformation": { "title": "Informace o zkušební době", "description": "Tento licenční klíč umožňuje používat podnikové funkce po dobu sedmidenní zkušební doby. Abyste zachovali přístup k placeným funkcím po skončení zkušební doby, musíte aktivovat platnou osobní nebo podnikovou licenci. Pro podnikové licence kontaktujte sales@pangolin.net." } }, "form": { "useCaseQuestion": "Používáte Pangolin pro osobní nebo obchodní účely?", "firstName": "Křestní jméno", "lastName": "Příjmení", "jobTitle": "Pracovní pozice", "primaryUseQuestion": "Na co budete především používat Pangolin?", "industryQuestion": "Jaké je vaše odvětví?", "prospectiveUsersQuestion": "Kolik potenciálních uživatelů předpokládáte?", "prospectiveSitesQuestion": "Kolik předpokládáte lokalit (tunelů)?", "companyName": "Název společnosti", "countryOfResidence": "Země sídla společnosti", "stateProvinceRegion": "Stát / kraj / oblast", "postalZipCode": "PSČ", "companyWebsite": "Stránky společnosti", "companyPhoneNumber": "Telefonní číslo společnosti", "country": "Země", "phoneNumberOptional": "Telefonní číslo (nepovinné)", "complianceConfirmation": "Potvrzuji, že informace které jsem poskytl jsou přesné a že jsem v souladu s licencí Fossorial Commercial License. Poskutnutí nepřesných informací nebo nesprávné určení použití produktu je porušením licence a může vést ke zrušení platnosti klíče." }, "buttons": { "close": "Zavřít", "previous": "Předchozí", "next": "Následující", "generateLicenseKey": "Vygenerovat licenční klíč" }, "toasts": { "success": { "title": "Licenční klíč byl úspěšně vytvořen", "description": "Váš licenční klíč byl vytvořen a je připraven k použití." }, "error": { "title": "Nepodařilo se vytvořit licenční klíč", "description": "Při generování licenčního klíče došlo k chybě." } } }, "newPricingLicenseForm": { "title": "Získat licenci", "description": "Vyberte si plán a řekněte nám, jak plánujete používat Pangolin.", "chooseTier": "Vyberte si svůj plán", "viewPricingLink": "Zobrazit ceny, funkce a limity", "tiers": { "starter": { "title": "Počáteční", "description": "Firemní funkce, 25 uživatelů, 25 stránek a komunitní podpory." }, "scale": { "title": "Měřítko", "description": "Podnikové funkce, 50 uživatelů, 50 míst a prioritní podpory." } }, "personalUseOnly": "Pouze osobní použití (bezplatná licence – bez platby)", "buttons": { "continueToCheckout": "Pokračovat do pokladny" }, "toasts": { "checkoutError": { "title": "Chyba při objednávce", "description": "Nelze spustit objednávku. Zkuste to prosím znovu." } } }, "priority": "Priorita", "priorityDescription": "Vyšší priorita je vyhodnocena jako první. Priorita = 100 znamená automatické řazení (rozhodnutí systému). Pro vynucení manuální priority použijte jiné číslo.", "instanceName": "Název instance", "pathMatchModalTitle": "Nastavit porovnávání cest", "pathMatchModalDescription": "Nastavte jak se bude ověřovat, ze cesty příchozích požadavků se shodují.", "pathMatchType": "Typ shody", "pathMatchPrefix": "Prefix", "pathMatchExact": "Přesná", "pathMatchRegex": "Regex", "pathMatchValue": "Hodnota cesty", "clear": "Smazat", "saveChanges": "Uložit změny", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/cesta", "pathMatchPrefixHelp": "Příklad: /api se shoduje s cestami /api, /api/users atd.", "pathMatchExactHelp": "Příklad: /api se shoduje pouze s cestou /api", "pathMatchRegexHelp": "Příklad: ^/api/.* se shoduje s /api/cokoliv", "pathRewriteModalTitle": "Nastavit přepis cesty", "pathRewriteModalDescription": "Upravit shodující se cestu před odesláním požadavku do cíle.", "pathRewriteType": "Typ přepisu", "pathRewritePrefixOption": "Prefix - Nahradí prefix", "pathRewriteExactOption": "Přesný - Nahradí celou cestu", "pathRewriteRegexOption": "Regex - Nahrazení pomocí vzorů", "pathRewriteStripPrefixOption": "Odstranit prefix - Odstraní prefix", "pathRewriteValue": "Přepisovaná hodnota", "pathRewriteRegexPlaceholder": "/nová/$1", "pathRewriteDefaultPlaceholder": "/nová-cesta", "pathRewritePrefixHelp": "Nahradit odpovídající prefix touto hodnotou", "pathRewriteExactHelp": "Nahradit celou cestu touto hodnotou, pokud cesta přesně odpovídá", "pathRewriteRegexHelp": "K nahrazení použít capture groups jako $1, $2", "pathRewriteStripPrefixHelp": "Ponechte prázdné pro odstranění prefixu nebo zadejte nový prefix", "pathRewritePrefix": "Prefix", "pathRewriteExact": "Přesný", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Odstranit", "pathRewriteStripLabel": "odstranit", "sidebarEnableEnterpriseLicense": "Použít podnikovou licenci", "cannotbeUndone": "To nelze vrátit zpět.", "toConfirm": "potvrdit.", "deleteClientQuestion": "Jste si jisti, že chcete odstranit klienta z webu a organizace?", "clientMessageRemove": "Po odstranění se klient již nebude moci připojit k webu.", "sidebarLogs": "Logy", "request": "Žádost", "requests": "Požadavky", "logs": "Logy", "logsSettingsDescription": "Sledujte protokoly sbírané z této organizace", "searchLogs": "Hledat logy...", "action": "Akce", "actor": "Aktér", "timestamp": "Časové razítko", "accessLogs": "Protokoly přístupu", "exportCsv": "Exportovat CSV", "exportError": "Neznámá chyba při exportu CSV", "exportCsvTooltip": "V zadaném časovém rozmezí", "actorId": "ID aktéra", "allowedByRule": "Povoleno pomocí pravidla", "allowedNoAuth": "Povoleno bez ověření", "validAccessToken": "Platný přístupový token", "validHeaderAuth": "Valid header auth", "validPincode": "Valid Pincode", "validPassword": "Platné heslo", "validEmail": "Valid email", "validSSO": "Valid SSO", "resourceBlocked": "Zablokované zdroje", "droppedByRule": "Zrušeno pravidlem", "noSessions": "Žádné relace", "temporaryRequestToken": "Dočasný požadavek token", "noMoreAuthMethods": "No Valid Auth", "ip": "IP adresa", "reason": "Důvod", "requestLogs": "Záznamy požadavků", "requestAnalytics": "Vyžádat analýzu", "host": "Hostitel", "location": "Poloha", "actionLogs": "Záznamy akcí", "sidebarLogsRequest": "Záznamy požadavků", "sidebarLogsAccess": "Protokoly přístupu", "sidebarLogsAction": "Záznamy akcí", "logRetention": "Zaznamenávání záznamu", "logRetentionDescription": "Spravovat, jak dlouho jsou různé typy logů uloženy pro tuto organizaci nebo je zakázat", "requestLogsDescription": "Zobrazit podrobné protokoly požadavků pro zdroje v této organizaci", "requestAnalyticsDescription": "Zobrazit podrobnou analýzu požadavků pro zdroje v této organizaci", "logRetentionRequestLabel": "Zachování logu žádosti", "logRetentionRequestDescription": "Jak dlouho uchovávat záznamy požadavků", "logRetentionAccessLabel": "Zachování záznamu přístupu", "logRetentionAccessDescription": "Jak dlouho uchovávat přístupové záznamy", "logRetentionActionLabel": "Uchovávání protokolu akcí", "logRetentionActionDescription": "Jak dlouho uchovávat záznamy akcí", "logRetentionDisabled": "Zakázáno", "logRetention3Days": "3 dny", "logRetention7Days": "7 dní", "logRetention14Days": "14 dní", "logRetention30Days": "30 dní", "logRetention90Days": "90 dní", "logRetentionForever": "Navždy", "logRetentionEndOfFollowingYear": "Konec následujícího roku", "actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci", "accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci", "licenseRequiredToUse": "Pro použití této funkce je vyžadována licence Enterprise Edition . Tato funkce je také dostupná v Pangolin Cloud.", "ossEnterpriseEditionRequired": "Enterprise Edition je vyžadována pro použití této funkce. Tato funkce je také k dispozici v Pangolin Cloud.", "certResolver": "Oddělovač certifikátů", "certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.", "selectCertResolver": "Vyberte řešič certifikátů", "enterCustomResolver": "Zadejte vlastní rozlišovač", "preferWildcardCert": "Preferovat Wildcard certifikát", "unverified": "Neověřeno", "domainSetting": "Nastavení domény", "domainSettingDescription": "Konfigurace nastavení pro doménu", "preferWildcardCertDescription": "Pokuste se vygenerovat certifikát se zástupným znakem (vyžaduje správně nakonfigurovaný resolver certifikátu).", "recordName": "Název záznamu", "auto": "Automaticky", "TTL": "TTL", "howToAddRecords": "Jak přidat záznamy", "dnsRecord": "Záznamy DNS", "required": "Požadováno", "domainSettingsUpdated": "Nastavení domény bylo úspěšně aktualizováno", "orgOrDomainIdMissing": "Chybí ID organizace nebo domény", "loadingDNSRecords": "Načítání DNS záznamů...", "olmUpdateAvailableInfo": "Je k dispozici aktualizovaná verze Olm. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.", "client": "Zákazník", "proxyProtocol": "Nastavení proxy protokolu", "proxyProtocolDescription": "Konfigurace Proxy protokolu pro zachování klientských IP adres pro služby TCP.", "enableProxyProtocol": "Povolit Proxy protokol", "proxyProtocolInfo": "Zachovat IP adresu klienta pro TCP zálohy", "proxyProtocolVersion": "Verze proxy protokolu", "version1": " Verze 1 (doporučeno)", "version2": "Verze 2", "versionDescription": "Verze 1 je textová a široce podporovaná. Verze 2 je binární a efektivnější, ale méně kompatibilní.", "warning": "Varování", "proxyProtocolWarning": "Aplikace backend musí být nakonfigurována, aby mohla přijímat připojení k Proxy protokolu. Pokud vaše backend nepodporuje Proxy protokol, povolením tohoto protokolu dojde k přerušení všech připojení, takže toto povolíte pouze pokud víte, co děláte. Ujistěte se, že nastavíte svou backend a důvěřujte hlavičkám Proxy protokolu z Traefik.", "restarting": "Restartování...", "manual": "Ruční", "messageSupport": "Podpora zpráv", "supportNotAvailableTitle": "Podpora není k dispozici", "supportNotAvailableDescription": "Podpora není momentálně k dispozici. Můžete poslat e-mail na support@pangolin.net.", "supportRequestSentTitle": "Žádost o podporu odeslána", "supportRequestSentDescription": "Vaše zpráva byla úspěšně odeslána.", "supportRequestFailedTitle": "Nepodařilo se odeslat žádost", "supportRequestFailedDescription": "Při odesílání vaší žádosti o podporu došlo k chybě.", "supportSubjectRequired": "Předmět je povinný", "supportSubjectMaxLength": "Předmět musí být 255 znaků nebo méně", "supportMessageRequired": "Je vyžadována zpráva", "supportReplyTo": "Odpovědět na", "supportSubject": "Předmět", "supportSubjectPlaceholder": "Zadejte předmět", "supportMessage": "Zpráva", "supportMessagePlaceholder": "Zadejte svou zprávu", "supportSending": "Odesílání...", "supportSend": "Poslat", "supportMessageSent": "Zpráva odeslána!", "supportWillContact": "Brzy budeme v kontaktu!", "selectLogRetention": "Vyberte záznam", "terms": "Výrazy", "privacy": "Soukromí", "security": "Zabezpečení", "docs": "Dokumentace", "deviceActivation": "Aktivace zařízení", "deviceCodeInvalidFormat": "Kód musí být 9 znaků (např. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Neplatný nebo prošlý kód", "deviceCodeVerifyFailed": "Ověření kódu zařízení se nezdařilo", "deviceCodeValidating": "Ověřování kódu zařízení...", "deviceCodeVerifying": "Ověřování autorizace zařízení...", "signedInAs": "Přihlášen jako", "deviceCodeEnterPrompt": "Zadejte kód zobrazený na zařízení", "continue": "Pokračovat", "deviceUnknownLocation": "Neznámá poloha", "deviceAuthorizationRequested": "Tato autorizace byla vyžádána od {location} na {date}. Ujistěte se, že tomuto zařízení věříte, protože získá přístup k účtu.", "deviceLabel": "Zařízení: {deviceName}", "deviceWantsAccess": "chce přistupovat k vašemu účtu", "deviceExistingAccess": "Stávající přístup:", "deviceFullAccess": "Úplný přístup k vašemu účtu", "deviceOrganizationsAccess": "Přístup ke všem organizacím má přístup k vašemu účtu", "deviceAuthorize": "Autorizovat {applicationName}", "deviceConnected": "Zařízení připojeno!", "deviceAuthorizedMessage": "Zařízení má oprávnění k přístupu k vašemu účtu. Vraťte se prosím do klientské aplikace.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "Zobrazit zařízení", "viewDevicesDescription": "Spravovat připojená zařízení", "noDevices": "Nebyla nalezena žádná zařízení", "dateCreated": "Datum vytvoření", "unnamedDevice": "Nepojmenované zařízení", "deviceQuestionRemove": "Jste si jisti, že chcete odstranit toto zařízení?", "deviceMessageRemove": "Tuto akci nelze vrátit zpět.", "deviceDeleteConfirm": "Odstranit zařízení", "deleteDevice": "Odstranit zařízení", "errorLoadingDevices": "Chyba při načítání zařízení", "failedToLoadDevices": "Načtení zařízení se nezdařilo", "deviceDeleted": "Zařízení odstraněno", "deviceDeletedDescription": "Zařízení bylo úspěšně smazáno.", "errorDeletingDevice": "Chyba při odstraňování zařízení", "failedToDeleteDevice": "Odstranění zařízení se nezdařilo", "showColumns": "Zobrazit sloupce", "hideColumns": "Skrýt sloupce", "columnVisibility": "Viditelnost sloupců", "toggleColumn": "Přepnout sloupec {columnName}", "allColumns": "Všechny sloupce", "defaultColumns": "Výchozí sloupce", "customizeView": "Přizpůsobit zobrazení", "viewOptions": "Možnosti zobrazení", "selectAll": "Vybrat vše", "selectNone": "Nevybrat žádný", "selectedResources": "Vybrané zdroje", "enableSelected": "Povolit vybrané", "disableSelected": "Zakázat vybrané", "checkSelectedStatus": "Zkontrolovat stav vybraného", "clients": "Klienti", "accessClientSelect": "Vyberte klienty stroje", "resourceClientDescription": "Strojové klienty, kteří mají přístup k tomuto zdroji", "regenerate": "Regenerovat", "credentials": "Pověření", "savecredentials": "Uložit přihlašovací údaje", "regenerateCredentialsButton": "Obnovit přihlašovací údaje", "regenerateCredentials": "Obnovit přihlašovací údaje", "generatedcredentials": "Vygenerovaná pověření", "copyandsavethesecredentials": "Zkopírovat a ukládat tato pověření", "copyandsavethesecredentialsdescription": "Tyto přihlašovací údaje se znovu nezobrazí po opuštění této stránky. Uložte je bezpečně.", "credentialsSaved": "Pověření uloženo", "credentialsSavedDescription": "Pověření byla úspěšně obnovena a uložena.", "credentialsSaveError": "Chyba při ukládání pověření", "credentialsSaveErrorDescription": "Došlo k chybě při obnovování a ukládání přihlašovacích údajů.", "regenerateCredentialsWarning": "Obnovení přihlašovacích údajů zneplatní předchozí a způsobí odpojení. Ujistěte se, že aktualizujete všechny konfigurace, které tyto přihlašovací údaje používají.", "confirm": "Potvrdit", "regenerateCredentialsConfirmation": "Jste si jisti, že chcete obnovit přihlašovací údaje?", "endpoint": "Endpoint", "Id": "Id", "SecretKey": "Tajný klíč", "niceId": "Pěkné ID", "niceIdUpdated": "Nice ID aktualizováno", "niceIdUpdatedSuccessfully": "Nice ID úspěšně aktualizováno", "niceIdUpdateError": "Chyba při aktualizaci Nice ID", "niceIdUpdateErrorDescription": "Došlo k chybě při aktualizaci identifikátoru Nice.", "niceIdCannotBeEmpty": "Nice ID nemůže být prázdné", "enterIdentifier": "Zadejte identifikátor", "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Nejste vy? Použijte jiný účet.", "deviceLoginDeviceRequestingAccessToAccount": "Zařízení žádá o přístup k tomuto účtu.", "loginSelectAuthenticationMethod": "Chcete-li pokračovat, vyberte metodu ověřování.", "noData": "Žádná data", "machineClients": "Strojoví klienti", "install": "Instalovat", "run": "Spustit", "clientNameDescription": "Zobrazované jméno klienta, které lze později změnit.", "clientAddress": "Adresa klienta (Rozšířeno)", "setupFailedToFetchSubnet": "Nepodařilo se načíst výchozí podsíť", "setupSubnetAdvanced": "Podsíť (předplacená)", "setupSubnetDescription": "Podsíť pro vnitřní síť této organizace.", "setupUtilitySubnet": "Nástrojový podsíť (Pokročilé)", "setupUtilitySubnetDescription": "Podsíť pro alias adresy a DNS server této organizace.", "siteRegenerateAndDisconnect": "Obnovit a odpojit", "siteRegenerateAndDisconnectConfirmation": "Opravdu chcete obnovit přihlašovací údaje a odpojit tuto stránku?", "siteRegenerateAndDisconnectWarning": "Toto obnoví přihlašovací údaje a okamžitě odpojí stránku. Stránka bude muset být restartována s novými přihlašovacími údaji.", "siteRegenerateCredentialsConfirmation": "Jste si jisti, že chcete obnovit přihlašovací údaje pro tuto stránku?", "siteRegenerateCredentialsWarning": "Toto obnoví přihlašovací údaje. Stránka zůstane připojena, dokud ji ručně nerestartujete a nepoužijete nové přihlašovací údaje.", "clientRegenerateAndDisconnect": "Obnovit a odpojit", "clientRegenerateAndDisconnectConfirmation": "Opravdu chcete obnovit přihlašovací údaje a odpojit tohoto klienta?", "clientRegenerateAndDisconnectWarning": "Toto obnoví přihlašovací údaje a okamžitě odpojí klienta. Klient bude muset být restartován s novými přihlašovacími údaji.", "clientRegenerateCredentialsConfirmation": "Opravdu chcete obnovit přihlašovací údaje pro tohoto klienta?", "clientRegenerateCredentialsWarning": "Toto obnoví přihlašovací údaje. Klient zůstane připojen, dokud jej ručně nerestartujete a nepoužije nové přihlašovací údaje.", "remoteExitNodeRegenerateAndDisconnect": "Obnovit a odpojit", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Jste si jisti, že chcete obnovit přihlašovací údaje a odpojit tento vzdálený výstupní uzel?", "remoteExitNodeRegenerateAndDisconnectWarning": "Toto obnoví přihlašovací údaje a okamžitě odpojí vzdálený výstupní uzel. Vzdálený výstupní uzel bude muset být restartován s novými přihlašovacími údaji.", "remoteExitNodeRegenerateCredentialsConfirmation": "Jste si jisti, že chcete obnovit přihlašovací údaje pro tento vzdálený výstupní uzel?", "remoteExitNodeRegenerateCredentialsWarning": "Toto obnoví přihlašovací údaje. Vzdálený výstupní uzel zůstane připojen, dokud jej ručně nerestartujete a nepoužijete nové přihlašovací údaje.", "agent": "Agent", "personalUseOnly": "Pouze pro osobní použití", "loginPageLicenseWatermark": "Tato instance je licencována pouze pro osobní použití.", "instanceIsUnlicensed": "Tato instance není licencována.", "portRestrictions": "Omezení portů", "allPorts": "Vše", "custom": "Vlastní", "allPortsAllowed": "Všechny porty povoleny", "allPortsBlocked": "Všechny porty blokovány", "tcpPortsDescription": "Určete, které TCP porty jsou pro tento prostředek povoleny. Použijte „*“ pro všechny porty, nechte prázdné pro zablokování všech, nebo zadejte seznam portů a rozsahů oddělených čárkou (např. 80,443,8000-9000).", "udpPortsDescription": "Určete, které UDP porty jsou pro tento prostředek povoleny. Použijte „*“ pro všechny porty, nechte prázdné pro zablokování všech, nebo zadejte seznam portů a rozsahů oddělených čárkou (např. 53,123,500-600).", "organizationLoginPageTitle": "Přihlašovací stránka organizace", "organizationLoginPageDescription": "Přizpůsobte přihlašovací stránku této organizace", "resourceLoginPageTitle": "Přihlašovací stránka prostředku", "resourceLoginPageDescription": "Přizpůsobte přihlašovací stránku jednotlivých prostředků", "enterConfirmation": "Zadejte potvrzení", "blueprintViewDetails": "Detaily", "defaultIdentityProvider": "Výchozí poskytovatel identity", "defaultIdentityProviderDescription": "Pokud je vybrán výchozí poskytovatel identity, uživatel bude automaticky přesměrován na poskytovatele pro ověření.", "editInternalResourceDialogNetworkSettings": "Nastavení sítě", "editInternalResourceDialogAccessPolicy": "Přístupová politika", "editInternalResourceDialogAddRoles": "Přidat role", "editInternalResourceDialogAddUsers": "Přidat uživatele", "editInternalResourceDialogAddClients": "Přidat klienty", "editInternalResourceDialogDestinationLabel": "Cíl", "editInternalResourceDialogDestinationDescription": "Určete cílovou adresu pro interní prostředek. Může se jednat o hostname, IP adresu, nebo rozsah CIDR v závislosti na vybraném režimu. Volitelně nastavte interní DNS alias pro snazší identifikaci.", "editInternalResourceDialogPortRestrictionsDescription": "Omezte přístup na specifické TCP/UDP porty nebo povolte/blokujte všechny porty.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Řízení přístupu", "editInternalResourceDialogAccessControlDescription": "Kontrolujte, které role, uživatelé a klienti mohou přistupovat k tomuto prostředku, když jsou připojeni. Admini mají vždy přístup.", "editInternalResourceDialogPortRangeValidationError": "Rozsah portů musí být \"*\" pro všechny porty, nebo seznam portů a rozsahů oddělených čárkou (např. \"80,443,8000-9000\"). Porty musí být mezi 1 a 65535.", "internalResourceAuthDaemonStrategy": "SSH Auth Démon umístění", "internalResourceAuthDaemonStrategyDescription": "Zvolte, kde běží SSH autentizační démon: na stránce (Newt) nebo na vzdáleném serveru.", "internalResourceAuthDaemonDescription": "SSH autentizační daemon zpracovává podpis SSH klíče a PAM autentizaci tohoto zdroje. Vyberte si, zda běží na webu (Newt) nebo na samostatném vzdáleném serveru. Více informací najdete v dokumentaci.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Vybrat strategii", "internalResourceAuthDaemonStrategyLabel": "Poloha", "internalResourceAuthDaemonSite": "Na stránce", "internalResourceAuthDaemonSiteDescription": "Auth daemon běží na webu (Newt).", "internalResourceAuthDaemonRemote": "Vzdálený server", "internalResourceAuthDaemonRemoteDescription": "Auth daemon běží na hostitele, který není web.", "internalResourceAuthDaemonPort": "Daemon port (volitelné)", "orgAuthWhatsThis": "Kde najdu ID mé organizace?", "learnMore": "Zjistit více", "backToHome": "Zpět na domovskou stránku", "needToSignInToOrg": "Potřebujete použít identitního poskytovatele vaší organizace?", "maintenanceMode": "Režim údržby", "maintenanceModeDescription": "Zobrazit stránku údržby návštěvníkům", "maintenanceModeType": "Typ režimu údržby", "showMaintenancePage": "Zobrazit stránku údržby návštěvníkům", "enableMaintenanceMode": "Povolit režim údržby", "automatic": "Automatické", "automaticModeDescription": "Zobrazte stránku údržby pouze, když jsou všechny cílové servery uživatele nebo prostředku nefunkční nebo nezdravé. Vaše prostředky budou nadále fungovat normálně, pokud je alespoň jeden cíl v pořádku.", "forced": "Nucené", "forcedModeDescription": "Vždy zobrazujte stránku údržby bez ohledu na stav backendu. Použijte to pro plánovanou údržbu, když chcete zabránit všem přístupům.", "warning:": "Varování:", "forcedeModeWarning": "Veškerý provoz bude směrován na stránku údržby. Vaše prostředky backendu neobdrží žádné žádosti.", "pageTitle": "Název stránky", "pageTitleDescription": "Hlavní titulek zobrazovaný na stránce údržby", "maintenancePageMessage": "Zpráva údržby", "maintenancePageMessagePlaceholder": "Vrátíme se brzy! Naše stránka právě prochází plánovanou údrbou.", "maintenancePageMessageDescription": "Podrobná zpráva vysvětlující údržbu", "maintenancePageTimeTitle": "Odhadovaný čas dokončení (volitelný)", "maintenanceTime": "např. 2 hodiny, 1. listopadu v 17:00", "maintenanceEstimatedTimeDescription": "Kdy očekáváte, že údržba bude dokončena", "editDomain": "Upravit doménu", "editDomainDescription": "Vyberte doménu pro váš prostředek", "maintenanceModeDisabledTooltip": "Tato funkce vyžaduje platnou licenci, aby ji bylo možné povolit.", "maintenanceScreenTitle": "Služba dočasně nedostupná", "maintenanceScreenMessage": "Momentálně máme technické potíže. Zkontrolujte později.", "maintenanceScreenEstimatedCompletion": "Odhadované dokončení:", "createInternalResourceDialogDestinationRequired": "Cíl je povinný", "available": "Dostupné", "archived": "Archivováno", "noArchivedDevices": "Nebyla nalezena žádná archivovaná zařízení", "deviceArchived": "Zařízení archivováno", "deviceArchivedDescription": "Zařízení bylo úspěšně archivováno.", "errorArchivingDevice": "Chyba při archivaci zařízení", "failedToArchiveDevice": "Archivace zařízení se nezdařila", "deviceQuestionArchive": "Opravdu chcete archivovat toto zařízení?", "deviceMessageArchive": "Zařízení bude archivováno a odebráno ze seznamu aktivních zařízení.", "deviceArchiveConfirm": "Archivovat zařízení", "archiveDevice": "Archivovat zařízení", "archive": "Archiv", "deviceUnarchived": "Zařízení bylo odarchivováno", "deviceUnarchivedDescription": "Zařízení bylo úspěšně odarchivováno.", "errorUnarchivingDevice": "Chyba při odarchivování zařízení", "failedToUnarchiveDevice": "Nepodařilo se odarchivovat zařízení", "unarchive": "Zrušit archiv", "archiveClient": "Archivovat klienta", "archiveClientQuestion": "Jste si jisti, že chcete archivovat tohoto klienta?", "archiveClientMessage": "Klient bude archivován a odstraněn z vašeho aktivního seznamu klientů.", "archiveClientConfirm": "Archivovat klienta", "blockClient": "Blokovat klienta", "blockClientQuestion": "Jste si jisti, že chcete zablokovat tohoto klienta?", "blockClientMessage": "Zařízení bude nuceno odpojit, pokud je připojeno. Zařízení můžete později odblokovat.", "blockClientConfirm": "Blokovat klienta", "active": "Aktivní", "usernameOrEmail": "Uživatelské jméno nebo e-mail", "selectYourOrganization": "Vyberte vaši organizaci", "signInTo": "Přihlásit se do", "signInWithPassword": "Pokračovat s heslem", "noAuthMethodsAvailable": "Pro tuto organizaci nejsou k dispozici žádné metody ověřování.", "enterPassword": "Zadejte své heslo", "enterMfaCode": "Zadejte kód z vaší ověřovací aplikace", "securityKeyRequired": "Pro přihlášení použijte svůj bezpečnostní klíč.", "needToUseAnotherAccount": "Potřebujete použít jiný účet?", "loginLegalDisclaimer": "Kliknutím na tlačítka níže potvrzujete, že jste si přečetli, chápali, a souhlasím s obchodními podmínkami a Zásadami ochrany osobních údajů.", "termsOfService": "Podmínky služby", "privacyPolicy": "Ochrana osobních údajů", "userNotFoundWithUsername": "Nebyl nalezen žádný uživatel s tímto uživatelským jménem.", "verify": "Ověřit", "signIn": "Přihlásit se", "forgotPassword": "Zapomněli jste heslo?", "orgSignInTip": "Pokud jste se přihlásili dříve, můžete místo toho zadat své uživatelské jméno nebo e-mail výše pro ověření u poskytovatele identity vaší organizace. Je to jednodušší!", "continueAnyway": "Přesto pokračovat", "dontShowAgain": "Znovu nezobrazovat", "orgSignInNotice": "Věděli jste, že?", "signupOrgNotice": "Chcete se přihlásit?", "signupOrgTip": "Snažíte se přihlásit prostřednictvím poskytovatele identity vaší organizace?", "signupOrgLink": "Namísto toho se přihlaste nebo se zaregistrujte pomocí své organizace", "verifyEmailLogInWithDifferentAccount": "Použít jiný účet", "logIn": "Přihlásit se", "deviceInformation": "Informace o zařízení", "deviceInformationDescription": "Informace o zařízení a agentovi", "deviceSecurity": "Zabezpečení zařízení", "deviceSecurityDescription": "Informace o bezpečnostní pozici zařízení", "platform": "Platforma", "macosVersion": "macOS verze", "windowsVersion": "Verze Windows", "iosVersion": "Verze iOS", "androidVersion": "Verze Androidu", "osVersion": "Verze OS", "kernelVersion": "Verze jádra", "deviceModel": "Model zařízení", "serialNumber": "Pořadové číslo", "hostname": "Hostname", "firstSeen": "První vidění", "lastSeen": "Naposledy viděno", "biometricsEnabled": "Biometrie povolena", "diskEncrypted": "Šifrovaný disk", "firewallEnabled": "Firewall povolen", "autoUpdatesEnabled": "Automatické aktualizace povoleny", "tpmAvailable": "TPM k dispozici", "windowsAntivirusEnabled": "Antivirus povolen", "macosSipEnabled": "Ochrana systémové integrity (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Režim neviditelnosti firewallu", "linuxAppArmorEnabled": "Pancíř aplikace", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "Zobrazit informace o zařízení a nastavení", "devicePendingApprovalDescription": "Toto zařízení čeká na schválení", "deviceBlockedDescription": "Toto zařízení je momentálně blokováno. Nebude se moci připojit k žádným zdrojům, dokud nebude odblokováno.", "unblockClient": "Odblokovat klienta", "unblockClientDescription": "Zařízení bylo odblokováno", "unarchiveClient": "Zrušit archiv klienta", "unarchiveClientDescription": "Zařízení bylo odarchivováno", "block": "Blokovat", "unblock": "Odblokovat", "deviceActions": "Akce zařízení", "deviceActionsDescription": "Spravovat stav zařízení a přístup", "devicePendingApprovalBannerDescription": "Toto zařízení čeká na schválení. Nebude se moci připojit ke zdrojům, dokud nebude schváleno.", "connected": "Připojeno", "disconnected": "Odpojeno", "approvalsEmptyStateTitle": "Schvalování zařízení není povoleno", "approvalsEmptyStateDescription": "Povolte oprávnění oprávnění pro role správce před připojením nových zařízení.", "approvalsEmptyStateStep1Title": "Přejít na role", "approvalsEmptyStateStep1Description": "Přejděte do nastavení rolí vaší organizace pro konfiguraci schválení zařízení.", "approvalsEmptyStateStep2Title": "Povolit schválení zařízení", "approvalsEmptyStateStep2Description": "Upravte roli a povolte možnost 'Vyžadovat schválení zařízení'. Uživatelé s touto rolí budou potřebovat schválení pro nová zařízení správce.", "approvalsEmptyStatePreviewDescription": "Náhled: Pokud je povoleno, čekající na zařízení se zde zobrazí žádosti o recenzi", "approvalsEmptyStateButtonText": "Spravovat role" } ================================================ FILE: messages/de-DE.json ================================================ { "setupCreate": "Organisation, Standort und Ressourcen erstellen", "headerAuthCompatibilityInfo": "Aktivieren Sie dies, um eine 401 Nicht autorisierte Antwort zu erzwingen, wenn ein Authentifizierungs-Token fehlt. Dies ist erforderlich für Browser oder bestimmte HTTP-Bibliotheken, die keine Anmeldedaten ohne Server-Challenge senden.", "headerAuthCompatibility": "Erweiterte Kompatibilität", "setupNewOrg": "Neue Organisation", "setupCreateOrg": "Organisation erstellen", "setupCreateResources": "Ressourcen erstellen", "setupOrgName": "Name der Organisation", "orgDisplayName": "Dies ist der Anzeigename der Organisation.", "orgId": "Organisations-ID", "setupIdentifierMessage": "Dies ist der eindeutige Bezeichner für die Organisation.", "setupErrorIdentifier": "Organisations-ID ist bereits vergeben. Bitte wähle eine andere.", "componentsErrorNoMemberCreate": "Du bist derzeit kein Mitglied einer Organisation. Erstelle eine Organisation, um zu starten.", "componentsErrorNoMember": "Du bist aktuell kein Mitglied einer Organisation.", "welcome": "Willkommen bei Pangolin!", "welcomeTo": "Willkommen bei", "componentsCreateOrg": "Erstelle eine Organisation", "componentsMember": "Du bist Mitglied von {count, plural, =0 {keiner Organisation} one {einer Organisation} other {# Organisationen}}.", "componentsInvalidKey": "Ungültige oder abgelaufene Lizenzschlüssel erkannt. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "dismiss": "Verwerfen", "subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Webseiten, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.", "subscriptionViolationViewBilling": "Rechnung anzeigen", "componentsLicenseViolation": "Lizenzverstoß: Dieser Server benutzt {usedSites} Standorte, was das Lizenzlimit von {maxSites} Standorten überschreitet. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "componentsSupporterMessage": "Vielen Dank für die Unterstützung von Pangolin als {tier}!", "inviteErrorNotValid": "Es tut uns leid, aber es sieht so aus, als wäre die Einladung, auf die du zugreifen möchtest, entweder nicht angenommen worden oder nicht mehr gültig.", "inviteErrorUser": "Es tut uns leid, aber es scheint, als sei die Einladung, auf die du zugreifen möchtest, nicht für diesen Benutzer bestimmt.", "inviteLoginUser": "Bitte stelle sicher, dass du als korrekter Benutzer angemeldet bist.", "inviteErrorNoUser": "Es tut uns leid, aber es sieht so aus, als sei die Einladung, auf die du zugreifen möchtest, nicht für einen existierenden Benutzer bestimmt.", "inviteCreateUser": "Bitte erstelle zuerst ein Konto.", "goHome": "Zur Startseite", "inviteLogInOtherUser": "Als anderer Benutzer anmelden", "createAnAccount": "Konto erstellen", "inviteNotAccepted": "Einladung nicht angenommen", "authCreateAccount": "Erstelle ein Konto um loszulegen", "authNoAccount": "Du besitzt noch kein Konto?", "email": "E-Mail", "password": "Passwort", "confirmPassword": "Passwort bestätigen", "createAccount": "Konto erstellen", "viewSettings": "Einstellungen anzeigen", "delete": "Löschen", "name": "Name", "online": "Online", "offline": "Offline", "site": "Standort", "dataIn": "Daten eingehend", "dataOut": "Daten ausgehend", "connectionType": "Verbindungstyp", "tunnelType": "Tunneltyp", "local": "Lokal", "edit": "Bearbeiten", "siteConfirmDelete": "Löschen des Standorts bestätigen", "siteDelete": "Standort löschen", "siteMessageRemove": "Sobald der Standort entfernt ist, wird sie nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.", "siteQuestionRemove": "Sind Sie sicher, dass Sie den Standort aus der Organisation entfernen möchten?", "siteManageSites": "Standorte verwalten", "siteDescription": "Erstellen und Verwalten von Standorten, um die Verbindung zu privaten Netzwerken zu ermöglichen", "sitesBannerTitle": "Verbinde ein beliebiges Netzwerk", "sitesBannerDescription": "Ein Standort ist eine Verbindung zu einem Remote-Netzwerk, die es Pangolin ermöglicht, Zugriff auf öffentliche oder private Ressourcen für Benutzer überall zu gewähren. Installieren Sie den Site Netzwerk Connector (Newt) wo auch immer Sie eine Binärdatei oder einen Container starten können, um die Verbindung herzustellen.", "sitesBannerButtonText": "Standort installieren", "approvalsBannerTitle": "Gerätezugriff genehmigen oder verweigern", "approvalsBannerDescription": "Überprüfen und genehmigen oder verweigern Gerätezugriffsanfragen von Benutzern. Wenn Gerätegenehmigungen erforderlich sind, müssen Benutzer eine Administratorgenehmigung erhalten, bevor ihre Geräte sich mit den Ressourcen Ihrer Organisation verbinden können.", "approvalsBannerButtonText": "Mehr erfahren", "siteCreate": "Standort erstellen", "siteCreateDescription2": "Folge den nachfolgenden Schritten, um einen neuen Standort zu erstellen und zu verbinden", "siteCreateDescription": "Erstellen Sie einen neuen Standort, um Ressourcen zu verbinden", "close": "Schließen", "siteErrorCreate": "Fehler beim Erstellen des Standortes", "siteErrorCreateKeyPair": "Schlüsselpaar oder Standardwerte nicht gefunden", "siteErrorCreateDefaults": "Standardwerte des Standortes nicht gefunden", "method": "Methode", "siteMethodDescription": "So werden Verbindungen freigegeben.", "siteLearnNewt": "Wie du Newt auf deinem System installieren kannst", "siteSeeConfigOnce": "Du kannst die Konfiguration nur einmalig ansehen.", "siteLoadWGConfig": "Lade WireGuard Konfiguration...", "siteDocker": "Docker-Details anzeigen", "toggle": "Umschalten", "dockerCompose": "Docker Compose", "dockerRun": "Docker Run", "siteLearnLocal": "Mehr Infos zum lokalen Standort", "siteConfirmCopy": "Ich habe die Konfiguration kopiert", "searchSitesProgress": "Standorte durchsuchen...", "siteAdd": "Standort hinzufügen", "siteInstallNewt": "Newt installieren", "siteInstallNewtDescription": "Installiere Newt auf deinem System.", "WgConfiguration": "WireGuard Konfiguration", "WgConfigurationDescription": "Verwenden Sie folgende Konfiguration, um sich mit dem Netzwerk zu verbinden", "operatingSystem": "Betriebssystem", "commands": "Befehle", "recommended": "Empfohlen", "siteNewtDescription": "Nutze Newt für die beste Benutzererfahrung. Newt verwendet WireGuard als Basis und erlaubt Ihnen, Ihre privaten Ressourcen über ihre LAN-Adresse in Ihrem privaten Netzwerk aus dem Pangolin-Dashboard heraus zu adressieren.", "siteRunsInDocker": "Läuft in Docker", "siteRunsInShell": "Läuft in der Konsole auf macOS, Linux und Windows", "siteErrorDelete": "Fehler beim Löschen des Standortes", "siteErrorUpdate": "Fehler beim Aktualisieren des Standortes", "siteErrorUpdateDescription": "Beim Aktualisieren des Standortes ist ein Fehler aufgetreten.", "siteUpdated": "Standort aktualisiert", "siteUpdatedDescription": "Der Standort wurde aktualisiert.", "siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren", "siteSettingDescription": "Standorteinstellungen konfigurieren", "siteSetting": "{siteName} Einstellungen", "siteNewtTunnel": "Newt Standort (empfohlen)", "siteNewtTunnelDescription": "Einfachster Weg, einen Einstiegspunkt in jedes Netzwerk zu erstellen. Keine zusätzliche Einrichtung.", "siteWg": "Einfacher WireGuard Tunnel", "siteWgDescription": "Verwende jeden WireGuard-Client, um einen Tunnel einzurichten. Manuelles NAT-Setup erforderlich.", "siteWgDescriptionSaas": "Verwenden Sie jeden WireGuard-Client, um einen Tunnel zu erstellen. Manuelles NAT-Setup erforderlich. FUNKTIONIERT NUR BEI SELBSTGEHOSTETEN KNOTEN", "siteLocalDescription": "Nur lokale Ressourcen. Kein Tunneling.", "siteLocalDescriptionSaas": "Nur lokale Ressourcen. Kein Tunneling. Nur für entfernte Knoten verfügbar.", "siteSeeAll": "Alle Standorte anzeigen", "siteTunnelDescription": "Legen Sie fest, wie Sie sich mit dem Standort verbinden möchten", "siteNewtCredentials": "Zugangsdaten", "siteNewtCredentialsDescription": "So wird sich der Standort mit dem Server authentifizieren", "remoteNodeCredentialsDescription": "So wird sich der entfernte Node mit dem Server authentifizieren", "siteCredentialsSave": "Anmeldedaten speichern", "siteCredentialsSaveDescription": "Du kannst das nur einmal sehen. Stelle sicher, dass du es an einen sicheren Ort kopierst.", "siteInfo": "Standortinformationen", "status": "Status", "shareTitle": "Links zum Teilen verwalten", "shareDescription": "Erstelle teilbare Links, um temporären oder permanenten Zugriff auf Proxy-Ressourcen zu gewähren", "shareSearch": "Freigabe-Links suchen...", "shareCreate": "Link erstellen", "shareErrorDelete": "Link konnte nicht gelöscht werden", "shareErrorDeleteMessage": "Fehler beim Löschen des Links", "shareDeleted": "Link gelöscht", "shareDeletedDescription": "Der Link wurde gelöscht", "shareTokenDescription": "Das Zugriffstoken kann auf zwei Arten übergeben werden: als Abfrageparameter oder in den Request-Headern. Diese müssen vom Client auf jeder Anfrage für authentifizierten Zugriff weitergegeben werden.", "accessToken": "Zugangs-Token", "usageExamples": "Nutzungsbeispiele", "tokenId": "Token-ID", "requestHeades": "Anfrage-Header", "queryParameter": "Abfrageparameter", "importantNote": "Wichtiger Hinweis", "shareImportantDescription": "Aus Sicherheitsgründen wird die Verwendung von Headern über Abfrageparameter empfohlen, wenn möglich, da Abfrageparameter in Server-Logs oder Browserverlauf protokolliert werden können.", "token": "Token", "shareTokenSecurety": "Bewahren Sie das Zugriffstoken sicher. Teilen Sie es nicht in öffentlich zugänglichen Bereichen oder Client-seitigem Code.", "shareErrorFetchResource": "Fehler beim Abrufen der Ressourcen", "shareErrorFetchResourceDescription": "Beim Abrufen der Ressourcen ist ein Fehler aufgetreten", "shareErrorCreate": "Fehler beim Erstellen des Teilen-Links", "shareErrorCreateDescription": "Beim Erstellen des Teilen-Links ist ein Fehler aufgetreten", "shareCreateDescription": "Jeder mit diesem Link kann auf die Ressource zugreifen", "shareTitleOptional": "Titel (optional)", "expireIn": "Verfällt in", "neverExpire": "Nie ablaufen", "shareExpireDescription": "Ablaufzeit ist, wie lange der Link verwendet werden kann und bietet Zugriff auf die Ressource. Nach dieser Zeit wird der Link nicht mehr funktionieren und Benutzer, die diesen Link benutzt haben, verlieren den Zugriff auf die Ressource.", "shareSeeOnce": "Sie können diesen Link nur einmal sehen. Bitte kopieren Sie ihn.", "shareAccessHint": "Jeder mit diesem Link kann auf die Ressource zugreifen. Teilen Sie sie mit Vorsicht.", "shareTokenUsage": "Zugriffstoken-Nutzung anzeigen", "createLink": "Link erstellen", "resourcesNotFound": "Keine Ressourcen gefunden", "resourceSearch": "Suche Ressourcen", "openMenu": "Menü öffnen", "resource": "Ressource", "title": "Titel", "created": "Erstellt", "expires": "Gültig bis", "never": "Nie", "shareErrorSelectResource": "Bitte wählen Sie eine Ressource", "proxyResourceTitle": "Öffentliche Ressourcen verwalten", "proxyResourceDescription": "Erstelle und verwalte Ressourcen, die über einen Webbrowser öffentlich zugänglich sind", "proxyResourcesBannerTitle": "Web-basierter öffentlicher Zugang", "proxyResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.", "clientResourceTitle": "Private Ressourcen verwalten", "clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind", "privateResourcesBannerTitle": "Zero-Trust Privater Zugang", "privateResourcesBannerDescription": "Private Ressourcen nutzen Zero-Trust und stellen sicher, dass Benutzer und Maschinen nur auf Ressourcen zugreifen können, die Sie explizit gewähren. Verbinden Sie Benutzergeräte oder Maschinen-Clients, um auf diese Ressourcen über ein sicheres virtuelles privates Netzwerk zuzugreifen.", "resourcesSearch": "Suche Ressourcen...", "resourceAdd": "Ressource hinzufügen", "resourceErrorDelte": "Fehler beim Löschen der Ressource", "authentication": "Authentifizierung", "protected": "Geschützt", "notProtected": "Nicht geschützt", "resourceMessageRemove": "Einmal entfernt, wird die Ressource nicht mehr zugänglich sein. Alle mit der Ressource verbundenen Ziele werden ebenfalls entfernt.", "resourceQuestionRemove": "Sind Sie sicher, dass Sie die Ressource aus der Organisation entfernen möchten?", "resourceHTTP": "HTTPS-Ressource", "resourceHTTPDescription": "Proxy-Anfragen über HTTPS mit einem voll qualifizierten Domain-Namen.", "resourceRaw": "Direkte TCP/UDP Ressource (raw)", "resourceRawDescription": "Proxy-Anfragen über rohes TCP/UDP mit einer Portnummer.", "resourceRawDescriptionCloud": "Proxy-Anfragen über rohe TCP/UDP mit einer Portnummer. Erfordert die NUTZUNG eines REMOTE Knotens.", "resourceCreate": "Ressource erstellen", "resourceCreateDescription": "Folgen Sie den Schritten unten, um eine neue Ressource zu erstellen", "resourceSeeAll": "Alle Ressourcen anzeigen", "resourceInfo": "Ressourcen-Informationen", "resourceNameDescription": "Dies ist der Anzeigename für die Ressource.", "siteSelect": "Standort auswählen", "siteSearch": "Standorte durchsuchen", "siteNotFound": "Keinen Standort gefunden.", "selectCountry": "Land auswählen", "searchCountries": "Länder suchen...", "noCountryFound": "Kein Land gefunden.", "siteSelectionDescription": "Dieser Standort wird die Verbindung zum Ziel herstellen.", "resourceType": "Ressourcentyp", "resourceTypeDescription": "Legen Sie fest, wie Sie auf die Ressource zugreifen", "resourceHTTPSSettings": "HTTPS-Einstellungen", "resourceHTTPSSettingsDescription": "Konfigurieren Sie den Zugriff auf die Ressource über HTTPS", "domainType": "Domain-Typ", "subdomain": "Subdomain", "baseDomain": "Basis-Domain", "subdomnainDescription": "Die Subdomäne, auf die die Ressource zugegriffen werden soll.", "resourceRawSettings": "TCP/UDP Einstellungen", "resourceRawSettingsDescription": "Konfigurieren, wie auf die Ressource über TCP/UDP zugegriffen wird", "protocol": "Protokoll", "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", "resourceAddEntrypoints": "Traefik: Einstiegspunkte hinzufügen", "resourceExposePorts": "Gerbil: Ports im Docker Compose freigeben", "resourceLearnRaw": "Lernen Sie, wie Sie TCP/UDP Ressourcen konfigurieren", "resourceBack": "Zurück zu den Ressourcen", "resourceGoTo": "Zu Ressource gehen", "resourceDelete": "Ressource löschen", "resourceDeleteConfirm": "Ressource löschen bestätigen", "visibility": "Sichtbarkeit", "enabled": "Aktiviert", "disabled": "Deaktiviert", "general": "Allgemein", "generalSettings": "Allgemeine Einstellungen", "proxy": "Proxy", "internal": "Intern", "rules": "Regeln", "resourceSettingDescription": "Einstellungen für die Ressource konfigurieren", "resourceSetting": "{resourceName} Einstellungen", "alwaysAllow": "Auth umgehen", "alwaysDeny": "Zugriff blockieren", "passToAuth": "Weiterleiten zur Authentifizierung", "orgSettingsDescription": "Organisationseinstellungen konfigurieren", "orgGeneralSettings": "Organisations-Einstellungen", "orgGeneralSettingsDescription": "Verwalten Sie die Details und Konfiguration der Organisation", "saveGeneralSettings": "Allgemeine Einstellungen speichern", "saveSettings": "Einstellungen speichern", "orgDangerZone": "Gefahrenzone", "orgDangerZoneDescription": "Sobald Sie diesen Org löschen, gibt es kein Zurück mehr. Bitte seien Sie vorsichtig.", "orgDelete": "Organisation löschen", "orgDeleteConfirm": "Organisation löschen bestätigen", "orgMessageRemove": "Diese Aktion ist unwiderruflich und löscht alle zugehörigen Daten.", "orgMessageConfirm": "Um zu bestätigen, geben Sie bitte den Namen der Organisation unten ein.", "orgQuestionRemove": "Sind Sie sicher, dass Sie die Organisation entfernen möchten?", "orgUpdated": "Organisation aktualisiert", "orgUpdatedDescription": "Die Organisation wurde aktualisiert.", "orgErrorUpdate": "Fehler beim Aktualisieren der Organisation", "orgErrorUpdateMessage": "Beim Aktualisieren der Organisation ist ein Fehler aufgetreten.", "orgErrorFetch": "Fehler beim Abrufen von Organisationen", "orgErrorFetchMessage": "Beim Auflisten Ihrer Organisationen ist ein Fehler aufgetreten", "orgErrorDelete": "Organisation konnte nicht gelöscht werden", "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", "accessUsersDescription": "Benutzer mit Zugriff auf diese Organisation einladen und verwalten", "accessUsersSearch": "Benutzer suchen...", "accessUserCreate": "Benutzer erstellen", "accessUserRemove": "Benutzer entfernen", "username": "Benutzername", "identityProvider": "Identitätsanbieter", "role": "Rolle", "nameRequired": "Name ist erforderlich", "accessRolesManage": "Rollen verwalten", "accessRolesDescription": "Erstellen und verwalten von Rollen für Benutzer in der Organisation", "accessRolesSearch": "Rollen suchen...", "accessRolesAdd": "Rolle hinzufügen", "accessRoleDelete": "Rolle löschen", "accessApprovalsManage": "Genehmigungen verwalten", "accessApprovalsDescription": "Zeige und verwalte ausstehende Genehmigungen für den Zugriff auf diese Organisation", "description": "Beschreibung", "inviteTitle": "Einladungen öffnen", "inviteDescription": "Einladungen für andere Benutzer verwalten, der Organisation beizutreten", "inviteSearch": "Einladungen suchen...", "minutes": "Minuten", "hours": "Stunden", "days": "Tage", "weeks": "Wochen", "months": "Monate", "years": "Jahre", "day": "{count, plural, one {# Tag} other {# Tage}}", "apiKeysTitle": "API-Schlüssel Information", "apiKeysConfirmCopy2": "Sie müssen bestätigen, dass Sie den API-Schlüssel kopiert haben.", "apiKeysErrorCreate": "Fehler beim Erstellen des API-Schlüssels", "apiKeysErrorSetPermission": "Fehler beim Setzen der Berechtigungen", "apiKeysCreate": "API-Schlüssel generieren", "apiKeysCreateDescription": "Einen neuen API-Schlüssel für die Organisation erstellen", "apiKeysGeneralSettings": "Berechtigungen", "apiKeysGeneralSettingsDescription": "Legen Sie fest, was dieser API-Schlüssel tun kann", "apiKeysList": "Neuer API-Schlüssel", "apiKeysSave": "API-Schlüssel speichern", "apiKeysSaveDescription": "Sie können dies nur einmal sehen. Kopieren Sie es an einen sicheren Ort.", "apiKeysInfo": "Der API-Schlüssel ist:", "apiKeysConfirmCopy": "Ich habe den API-Schlüssel kopiert", "generate": "Generieren", "done": "Fertig", "apiKeysSeeAll": "Alle API-Schlüssel anzeigen", "apiKeysPermissionsErrorLoadingActions": "Fehler beim Laden der API-Schlüsselaktionen", "apiKeysPermissionsErrorUpdate": "Fehler beim Setzen der Berechtigungen", "apiKeysPermissionsUpdated": "Berechtigungen aktualisiert", "apiKeysPermissionsUpdatedDescription": "Die Berechtigungen wurden aktualisiert.", "apiKeysPermissionsGeneralSettings": "Berechtigungen", "apiKeysPermissionsGeneralSettingsDescription": "Legen Sie fest, was dieser API-Schlüssel tun kann", "apiKeysPermissionsSave": "Berechtigungen speichern", "apiKeysPermissionsTitle": "Berechtigungen", "apiKeys": "API-Schlüssel", "searchApiKeys": "API-Schlüssel suchen...", "apiKeysAdd": "API-Schlüssel generieren", "apiKeysErrorDelete": "Fehler beim Löschen des API-Schlüssels", "apiKeysErrorDeleteMessage": "Fehler beim Löschen des API-Schlüssels", "apiKeysQuestionRemove": "Sind Sie sicher, dass Sie den API-Schlüssel aus der Organisation entfernen möchten?", "apiKeysMessageRemove": "Einmal entfernt, kann der API-Schlüssel nicht mehr verwendet werden.", "apiKeysDeleteConfirm": "Löschen des API-Schlüssels bestätigen", "apiKeysDelete": "API-Schlüssel löschen", "apiKeysManage": "API-Schlüssel verwalten", "apiKeysDescription": "API-Schlüssel werden zur Authentifizierung mit der Integrations-API verwendet", "apiKeysSettings": "{apiKeyName} Einstellungen", "userTitle": "Alle Benutzer verwalten", "userDescription": "Alle Benutzer im System anzeigen und verwalten", "userAbount": "Über Benutzerverwaltung", "userAbountDescription": "Diese Tabelle zeigt alle root-Benutzerobjekte im System an. Jeder Benutzer kann zu mehreren Organisationen gehören. Das Entfernen eines Benutzers aus einer Organisation löscht nicht sein Root-Benutzerobjekt - er bleibt im System. Um einen Benutzer komplett aus dem System zu entfernen, müssen Sie sein Root-Benutzerobjekt mit der Lösch-Aktion in dieser Tabelle löschen.", "userServer": "Server Benutzer", "userSearch": "Serverbenutzer suchen...", "userErrorDelete": "Fehler beim Löschen des Benutzers", "userDeleteConfirm": "Benutzer löschen bestätigen", "userDeleteServer": "Benutzer vom Server löschen", "userMessageRemove": "Der Benutzer wird von allen Organisationen entfernt und vollständig vom Server entfernt.", "userQuestionRemove": "Sind Sie sicher, dass Sie den Benutzer dauerhaft vom Server löschen möchten?", "licenseKey": "Lizenzschlüssel", "valid": "Gültig", "numberOfSites": "Anzahl der Standorte", "licenseKeySearch": "Lizenzschlüssel suchen...", "licenseKeyAdd": "Lizenzschlüssel hinzufügen", "type": "Typ", "licenseKeyRequired": "Lizenzschlüssel ist erforderlich", "licenseTermsAgree": "Sie müssen den Lizenzbedingungen zustimmen", "licenseErrorKeyLoad": "Fehler beim Laden der Lizenzschlüssel", "licenseErrorKeyLoadDescription": "Beim Laden der Lizenzschlüssel ist ein Fehler aufgetreten.", "licenseErrorKeyDelete": "Fehler beim Löschen des Lizenzschlüssels", "licenseErrorKeyDeleteDescription": "Beim Löschen des Lizenzschlüssels ist ein Fehler aufgetreten.", "licenseKeyDeleted": "Lizenzschlüssel gelöscht", "licenseKeyDeletedDescription": "Der Lizenzschlüssel wurde gelöscht.", "licenseErrorKeyActivate": "Fehler beim Aktivieren des Lizenzschlüssels", "licenseErrorKeyActivateDescription": "Beim Aktivieren des Lizenzschlüssels ist ein Fehler aufgetreten.", "licenseAbout": "Über Lizenzierung", "communityEdition": "Community-Edition", "licenseAboutDescription": "Dies ist für Geschäfts- und Unternehmensanwender, die Pangolin in einem kommerziellen Umfeld einsetzen. Wenn Sie Pangolin für den persönlichen Gebrauch verwenden, können Sie diesen Abschnitt ignorieren.", "licenseKeyActivated": "Lizenzschlüssel aktiviert", "licenseKeyActivatedDescription": "Der Lizenzschlüssel wurde erfolgreich aktiviert.", "licenseErrorKeyRecheck": "Fehler beim Überprüfen der Lizenzschlüssel", "licenseErrorKeyRecheckDescription": "Ein Fehler trat auf beim Wiederherstellen der Lizenzschlüssel.", "licenseErrorKeyRechecked": "Lizenzschlüssel neu geladen", "licenseErrorKeyRecheckedDescription": "Alle Lizenzschlüssel wurden neu geladen", "licenseActivateKey": "Lizenzschlüssel aktivieren", "licenseActivateKeyDescription": "Geben Sie einen Lizenzschlüssel ein, um ihn zu aktivieren.", "licenseActivate": "Lizenz aktivieren", "licenseAgreement": "Durch Ankreuzung dieses Kästchens bestätigen Sie, dass Sie die Lizenzbedingungen gelesen und akzeptiert haben, die mit dem Lizenzschlüssel in Verbindung stehen.", "fossorialLicense": "Fossorial Gewerbelizenz & Abonnementbedingungen anzeigen", "licenseMessageRemove": "Dadurch werden der Lizenzschlüssel und alle zugehörigen Berechtigungen entfernt.", "licenseMessageConfirm": "Um zu bestätigen, geben Sie bitte den Lizenzschlüssel unten ein.", "licenseQuestionRemove": "Sind Sie sicher, dass Sie den Lizenzschlüssel löschen möchten?", "licenseKeyDelete": "Lizenzschlüssel löschen", "licenseKeyDeleteConfirm": "Lizenzschlüssel löschen bestätigen", "licenseTitle": "Lizenzstatus verwalten", "licenseTitleDescription": "Lizenzschlüssel im System anzeigen und verwalten", "licenseHost": "Hostlizenz", "licenseHostDescription": "Verwalten Sie den Haupt-Lizenzschlüssel für den Host.", "licensedNot": "Nicht lizenziert", "hostId": "Host-ID", "licenseReckeckAll": "Überprüfe alle Schlüssel", "licenseSiteUsage": "Standort-Nutzung", "licenseSiteUsageDecsription": "Sehen Sie sich die Anzahl der Standorte an, die diese Lizenz verwenden.", "licenseNoSiteLimit": "Die Anzahl der Standorte, die einen nicht lizenzierten Host verwenden, ist unbegrenzt.", "licensePurchase": "Lizenz kaufen", "licensePurchaseSites": "Zusätzliche Standorte kaufen\n", "licenseSitesUsedMax": "{usedSites} von {maxSites} Standorten verwendet", "licenseSitesUsed": "{count, plural, =0 {# Standorte} one {# Standort} other {# Standorte}} im System.", "licensePurchaseDescription": "Wähle aus, für wieviele Seiten du möchtest {selectedMode, select, license {kaufe eine Lizenz. Du kannst später immer weitere Seiten hinzufügen.} other {Füge zu deiner bestehenden Lizenz hinzu.}}", "licenseFee": "Lizenzgebühr", "licensePriceSite": "Preis pro Standort", "total": "Gesamt", "licenseContinuePayment": "Weiter zur Zahlung", "pricingPage": "Preisseite", "pricingPortal": "Einkaufsportal ansehen", "licensePricingPage": "Für die aktuellsten Preise und Rabatte, besuchen Sie bitte die ", "invite": "Einladungen", "inviteRegenerate": "Einladung neu generieren", "inviteRegenerateDescription": "Vorherige Einladung widerrufen und neue erstellen", "inviteRemove": "Einladung entfernen", "inviteRemoveError": "Einladung konnte nicht entfernt werden", "inviteRemoveErrorDescription": "Beim Entfernen der Einladung ist ein Fehler aufgetreten.", "inviteRemoved": "Einladung entfernt", "inviteRemovedDescription": "Die Einladung für {email} wurde entfernt.", "inviteQuestionRemove": "Sind Sie sicher, dass Sie die Einladung entfernen möchten?", "inviteMessageRemove": "Sobald entfernt, wird diese Einladung nicht mehr gültig sein. Sie können den Benutzer später jederzeit erneut einladen.", "inviteMessageConfirm": "Bitte geben Sie zur Bestätigung die E-Mail-Adresse der Einladung unten ein.", "inviteQuestionRegenerate": "Sind Sie sicher, dass Sie die Einladung {email} neu generieren möchten? Dies wird die vorherige Einladung widerrufen.", "inviteRemoveConfirm": "Entfernen der Einladung bestätigen", "inviteRegenerated": "Einladung neu generiert", "inviteSent": "Eine neue Einladung wurde an {email} gesendet.", "inviteSentEmail": "E-Mail-Benachrichtigung an den Benutzer senden", "inviteGenerate": "Eine neue Einladung wurde für {email} generiert.", "inviteDuplicateError": "Doppelte Einladung", "inviteDuplicateErrorDescription": "Eine Einladung für diesen Benutzer existiert bereits.", "inviteRateLimitError": "Ratenlimit überschritten", "inviteRateLimitErrorDescription": "Sie haben das Limit von 3 Neugenerierungen pro Stunde überschritten. Bitte versuchen Sie es später erneut.", "inviteRegenerateError": "Fehler beim Neugenerieren der Einladung", "inviteRegenerateErrorDescription": "Beim Neugenerieren der Einladung ist ein Fehler aufgetreten.", "inviteValidityPeriod": "Gültigkeitszeitraum", "inviteValidityPeriodSelect": "Gültigkeitszeitraum auswählen", "inviteRegenerateMessage": "Die Einladung wurde neu generiert. Der Benutzer muss den untenstehenden Link aufrufen, um die Einladung anzunehmen.", "inviteRegenerateButton": "Neu generieren", "expiresAt": "Läuft ab am", "accessRoleUnknown": "Unbekannte Rolle", "placeholder": "Platzhalter", "userErrorOrgRemove": "Fehler beim Entfernen des Benutzers", "userErrorOrgRemoveDescription": "Beim Entfernen des Benutzers ist ein Fehler aufgetreten.", "userOrgRemoved": "Benutzer entfernt", "userOrgRemovedDescription": "Der Benutzer {email} wurde aus der Organisation entfernt.", "userQuestionOrgRemove": "Sind Sie sicher, dass Sie diesen Benutzer aus der Organisation entfernen möchten?", "userMessageOrgRemove": "Nach dem Entfernen hat dieser Benutzer keinen Zugriff mehr auf die Organisation. Sie können ihn später jederzeit wieder einladen, aber er muss die Einladung erneut annehmen.", "userRemoveOrgConfirm": "Entfernen des Benutzers bestätigen", "userRemoveOrg": "Benutzer aus der Organisation entfernen", "users": "Benutzer", "accessRoleMember": "Mitglied", "accessRoleOwner": "Eigentümer", "userConfirmed": "Bestätigt", "idpNameInternal": "Intern", "emailInvalid": "Ungültige E-Mail-Adresse", "inviteValidityDuration": "Bitte wählen Sie eine Dauer", "accessRoleSelectPlease": "Bitte wählen Sie eine Rolle", "usernameRequired": "Benutzername ist erforderlich", "idpSelectPlease": "Bitte wählen Sie einen Identitätsanbieter", "idpGenericOidc": "Generischer OAuth2/OIDC-Anbieter.", "accessRoleErrorFetch": "Fehler beim Abrufen der Rollen", "accessRoleErrorFetchDescription": "Beim Abrufen der Rollen ist ein Fehler aufgetreten", "idpErrorFetch": "Fehler beim Abrufen der Identitätsanbieter", "idpErrorFetchDescription": "Beim Abrufen der Identitätsanbieter ist ein Fehler aufgetreten", "userErrorExists": "Benutzer existiert bereits", "userErrorExistsDescription": "Dieser Benutzer ist bereits Mitglied der Organisation.", "inviteError": "Fehler beim Einladen des Benutzers", "inviteErrorDescription": "Beim Einladen des Benutzers ist ein Fehler aufgetreten", "userInvited": "Benutzer eingeladen", "userInvitedDescription": "Der Benutzer wurde erfolgreich eingeladen.", "userErrorCreate": "Fehler beim Erstellen des Benutzers", "userErrorCreateDescription": "Beim Erstellen des Benutzers ist ein Fehler aufgetreten", "userCreated": "Benutzer erstellt", "userCreatedDescription": "Der Benutzer wurde erfolgreich erstellt.", "userTypeInternal": "Interner Benutzer", "userTypeInternalDescription": "Lade einen Benutzer ein, der Organisation direkt beizutreten.", "userTypeExternal": "Externer Benutzer", "userTypeExternalDescription": "Erstellen Sie einen Benutzer mit einem externen Identitätsanbieter.", "accessUserCreateDescription": "Folgen Sie den Schritten unten, um einen neuen Benutzer zu erstellen", "userSeeAll": "Alle Benutzer anzeigen", "userTypeTitle": "Benutzertyp", "userTypeDescription": "Legen Sie fest, wie Sie den Benutzer erstellen möchten", "userSettings": "Benutzerinformationen", "userSettingsDescription": "Geben Sie die Details für den neuen Benutzer ein", "inviteEmailSent": "Einladungs-E-Mail an Benutzer senden", "inviteValid": "Gültig für", "selectDuration": "Dauer auswählen", "selectResource": "Ressource auswählen", "filterByResource": "Nach Ressource filtern", "selectApprovalState": "Genehmigungsstatus auswählen", "filterByApprovalState": "Filtern nach Genehmigungsstatus", "approvalListEmpty": "Keine Genehmigungen", "approvalState": "Genehmigungsstatus", "approvalLoadMore": "Mehr laden", "loadingApprovals": "Genehmigungen werden geladen", "approve": "Bestätigen", "approved": "Genehmigt", "denied": "Verweigert", "deniedApproval": "Genehmigung verweigert", "all": "Alle", "deny": "Leugnen", "viewDetails": "Details anzeigen", "requestingNewDeviceApproval": "hat ein neues Gerät angefordert", "resetFilters": "Filter zurücksetzen", "totalBlocked": "Anfragen blockiert von Pangolin", "totalRequests": "Gesamte Anfragen", "requestsByCountry": "Anfragen nach Land", "requestsByDay": "Anfragen nach Tag", "blocked": "Blockiert", "allowed": "Zulässig", "topCountries": "Top Länder", "accessRoleSelect": "Rolle auswählen", "inviteEmailSentDescription": "Eine E-Mail mit dem Zugangslink wurde an den Benutzer gesendet. Er muss den Link aufrufen, um die Einladung anzunehmen.", "inviteSentDescription": "Der Benutzer wurde eingeladen. Er muss den unten stehenden Link aufrufen, um die Einladung anzunehmen.", "inviteExpiresIn": "Die Einladung läuft in {days, plural, one {einem Tag} other {# Tagen}} ab.", "idpTitle": "Allgemeine Informationen", "idpSelect": "Wählen Sie den Identitätsanbieter für den externen Benutzer", "idpNotConfigured": "Es sind keine Identitätsanbieter konfiguriert. Bitte konfigurieren Sie einen Identitätsanbieter, bevor Sie externe Benutzer erstellen.", "usernameUniq": "Dies muss mit dem eindeutigen Benutzernamen übereinstimmen, der im ausgewählten Identitätsanbieter existiert.", "emailOptional": "E-Mail (Optional)", "nameOptional": "Name (optional)", "accessControls": "Zugriffskontrolle", "userDescription2": "Verwalten Sie die Einstellungen dieses Benutzers", "accessRoleErrorAdd": "Fehler beim Hinzufügen des Benutzers zur Rolle", "accessRoleErrorAddDescription": "Beim Hinzufügen des Benutzers zur Rolle ist ein Fehler aufgetreten.", "userSaved": "Benutzer gespeichert", "userSavedDescription": "Der Benutzer wurde aktualisiert.", "autoProvisioned": "Automatisch bereitgestellt", "autoProvisionedDescription": "Erlaube diesem Benutzer die automatische Verwaltung durch Identitätsanbieter", "accessControlsDescription": "Verwalten Sie, worauf dieser Benutzer in der Organisation zugreifen und was er tun kann", "accessControlsSubmit": "Zugriffskontrollen speichern", "roles": "Rollen", "accessUsersRoles": "Benutzer & Rollen verwalten", "accessUsersRolesDescription": "Lade Benutzer ein und füge sie zu Rollen hinzu, um den Zugriff auf die Organisation zu verwalten", "key": "Schlüssel", "createdAt": "Erstellt am", "proxyErrorInvalidHeader": "Ungültiger benutzerdefinierter Host-Header-Wert. Verwenden Sie das Domain-Namensformat oder speichern Sie leer, um den benutzerdefinierten Host-Header zu deaktivieren.", "proxyErrorTls": "Ungültiger TLS-Servername. Verwenden Sie das Domain-Namensformat oder speichern Sie leer, um den TLS-Servernamen zu entfernen.", "proxyEnableSSL": "SSL aktivieren", "proxyEnableSSLDescription": "Aktiviere SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zu den Zielen.", "target": "Ziel", "configureTarget": "Ziele konfigurieren", "targetErrorFetch": "Fehler beim Abrufen der Ziele", "targetErrorFetchDescription": "Beim Abrufen der Ziele ist ein Fehler aufgetreten", "siteErrorFetch": "Fehler beim Abrufen der Ressource", "siteErrorFetchDescription": "Beim Abrufen der Ressource ist ein Fehler aufgetreten", "targetErrorDuplicate": "Doppeltes Ziel", "targetErrorDuplicateDescription": "Ein Ziel mit diesen Einstellungen existiert bereits", "targetWireGuardErrorInvalidIp": "Ungültige Ziel-IP", "targetWireGuardErrorInvalidIpDescription": "Die Ziel-IP muss innerhalb des Standort-Subnets liegen", "targetsUpdated": "Ziele aktualisiert", "targetsUpdatedDescription": "Ziele und Einstellungen erfolgreich aktualisiert", "targetsErrorUpdate": "Fehler beim Aktualisieren der Ziele", "targetsErrorUpdateDescription": "Beim Aktualisieren der Ziele ist ein Fehler aufgetreten", "targetTlsUpdate": "TLS-Einstellungen aktualisiert", "targetTlsUpdateDescription": "TLS Einstellungen wurden erfolgreich aktualisiert", "targetErrorTlsUpdate": "Fehler beim Aktualisieren der TLS-Einstellungen", "targetErrorTlsUpdateDescription": "Beim Aktualisieren der TLS-Einstellungen ist ein Fehler aufgetreten", "proxyUpdated": "Proxy-Einstellungen aktualisiert", "proxyUpdatedDescription": "Proxy-Einstellungen wurden erfolgreich aktualisiert", "proxyErrorUpdate": "Fehler beim Aktualisieren der Proxy-Einstellungen", "proxyErrorUpdateDescription": "Beim Aktualisieren der Proxy-Einstellungen ist ein Fehler aufgetreten", "targetAddr": "Host", "targetPort": "Port", "targetProtocol": "Protokoll des Ziels", "targetTlsSettings": "Sicherheitskonfiguration", "targetTlsSettingsDescription": "SSL/TLS Einstellungen für die Ressource konfigurieren", "targetTlsSettingsAdvanced": "Erweiterte TLS-Einstellungen", "targetTlsSni": "TLS Servername", "targetTlsSniDescription": "Der zu verwendende TLS-Servername für SNI. Leer lassen, um den Standard zu verwenden.", "targetTlsSubmit": "Einstellungen speichern", "targets": "Ziel-Konfiguration", "targetsDescription": "Ziele zur Routenplanung für Backend-Dienste festlegen", "targetStickySessions": "Sitzungspersistenz aktivieren", "targetStickySessionsDescription": "Verbindungen während der gesamten Sitzung auf das gleiche Backend-Ziel leiten", "methodSelect": "Methode auswählen", "targetSubmit": "Ziel hinzufügen", "targetNoOne": "Diese Ressource hat keine Ziele. Fügen Sie ein Ziel hinzu, um zu konfigurieren, wo Anfragen an das Backend gesendet werden sollen.", "targetNoOneDescription": "Das Hinzufügen von mehr als einem Ziel aktiviert den Lastausgleich.", "targetsSubmit": "Ziele speichern", "addTarget": "Ziel hinzufügen", "targetErrorInvalidIp": "Ungültige IP-Adresse", "targetErrorInvalidIpDescription": "Bitte geben Sie eine gültige IP-Adresse oder einen Hostnamen ein", "targetErrorInvalidPort": "Ungültiger Port", "targetErrorInvalidPortDescription": "Bitte geben Sie eine gültige Portnummer ein", "targetErrorNoSite": "Kein Standort ausgewählt", "targetErrorNoSiteDescription": "Bitte wähle einen Standort für das Ziel aus", "targetCreated": "Ziel erstellt", "targetCreatedDescription": "Ziel wurde erfolgreich erstellt", "targetErrorCreate": "Fehler beim Erstellen des Ziels", "targetErrorCreateDescription": "Beim Erstellen des Ziels ist ein Fehler aufgetreten", "tlsServerName": "TLS Servername", "tlsServerNameDescription": "Der für SNI verwendete TLS-Servername", "save": "Speichern", "proxyAdditional": "Zusätzliche Proxy-Einstellungen", "proxyAdditionalDescription": "Konfigurieren Sie die Proxy-Einstellungen der Ressource", "proxyCustomHeader": "Benutzerdefinierter Host-Header", "proxyCustomHeaderDescription": "Der Host-Header, der beim Weiterleiten von Anfragen gesetzt werden soll. Leer lassen, um den Standard zu verwenden.", "proxyAdditionalSubmit": "Proxy-Einstellungen speichern", "subnetMaskErrorInvalid": "Ungültige Subnetzmaske. Muss zwischen 0 und 32 liegen.", "ipAddressErrorInvalidFormat": "Ungültiges IP-Adressformat", "ipAddressErrorInvalidOctet": "Ungültiges IP-Adress-Oktett", "path": "Pfad", "matchPath": "Match-Pfad", "ipAddressRange": "IP-Bereich", "rulesErrorFetch": "Fehler beim Abrufen der Regeln", "rulesErrorFetchDescription": "Beim Abrufen der Regeln ist ein Fehler aufgetreten", "rulesErrorDuplicate": "Doppelte Regel", "rulesErrorDuplicateDescription": "Eine Regel mit diesen Einstellungen existiert bereits", "rulesErrorInvalidIpAddressRange": "Ungültiger CIDR", "rulesErrorInvalidIpAddressRangeDescription": "Bitte geben Sie einen gültigen CIDR-Wert ein", "rulesErrorInvalidUrl": "Ungültiger URL-Pfad", "rulesErrorInvalidUrlDescription": "Bitte geben Sie einen gültigen URL-Pfad-Wert ein", "rulesErrorInvalidIpAddress": "Ungültige IP", "rulesErrorInvalidIpAddressDescription": "Bitte geben Sie eine gültige IP-Adresse ein", "rulesErrorUpdate": "Fehler beim Aktualisieren der Regeln", "rulesErrorUpdateDescription": "Beim Aktualisieren der Regeln ist ein Fehler aufgetreten", "rulesUpdated": "Regeln aktivieren", "rulesUpdatedDescription": "Die Regelauswertung wurde aktualisiert", "rulesMatchIpAddressRangeDescription": "Geben Sie eine Adresse im CIDR-Format ein (z.B. 103.21.244.0/22)", "rulesMatchIpAddress": "Geben Sie eine IP-Adresse ein (z.B. 103.21.244.12)", "rulesMatchUrl": "Geben Sie einen URL-Pfad oder -Muster ein (z.B. /api/v1/todos oder /api/v1/*)", "rulesErrorInvalidPriority": "Ungültige Priorität", "rulesErrorInvalidPriorityDescription": "Bitte geben Sie eine gültige Priorität ein", "rulesErrorDuplicatePriority": "Doppelte Prioritäten", "rulesErrorDuplicatePriorityDescription": "Bitte geben Sie eindeutige Prioritäten ein", "ruleUpdated": "Regeln aktualisiert", "ruleUpdatedDescription": "Regeln erfolgreich aktualisiert", "ruleErrorUpdate": "Operation fehlgeschlagen", "ruleErrorUpdateDescription": "Während des Speichervorgangs ist ein Fehler aufgetreten", "rulesPriority": "Priorität", "rulesAction": "Aktion", "rulesMatchType": "Übereinstimmungstyp", "value": "Wert", "rulesAbout": "Über Regeln", "rulesAboutDescription": "Regeln erlauben es Ihnen, den Zugriff auf die Ressource anhand einer Reihe von Kriterien zu kontrollieren. Sie können Regeln erstellen, die den Zugriff basierend auf IP-Adresse oder URL-Pfad erlauben oder verweigern.", "rulesActions": "Aktionen", "rulesActionAlwaysAllow": "Immer erlauben: Alle Authentifizierungsmethoden umgehen", "rulesActionAlwaysDeny": "Immer verweigern: Alle Anfragen blockieren; keine Authentifizierung möglich", "rulesActionPassToAuth": "Weiterleiten zur Authentifizierung: Erlaubt das Versuchen von Authentifizierungsmethoden", "rulesMatchCriteria": "Übereinstimmungskriterien", "rulesMatchCriteriaIpAddress": "Mit einer bestimmten IP-Adresse übereinstimmen", "rulesMatchCriteriaIpAddressRange": "Mit einem IP-Adressbereich in CIDR-Notation übereinstimmen", "rulesMatchCriteriaUrl": "Mit einem URL-Pfad oder -Muster übereinstimmen", "rulesEnable": "Regeln aktivieren", "rulesEnableDescription": "Regelauswertung für diese Ressource aktivieren oder deaktivieren", "rulesResource": "Ressourcen-Regelkonfiguration", "rulesResourceDescription": "Regeln konfigurieren, um den Zugriff auf die Ressource zu steuern", "ruleSubmit": "Regel hinzufügen", "rulesNoOne": "Keine Regeln. Fügen Sie eine Regel über das Formular hinzu.", "rulesOrder": "Regeln werden nach aufsteigender Priorität ausgewertet.", "rulesSubmit": "Regeln speichern", "resourceErrorCreate": "Fehler beim Erstellen der Ressource", "resourceErrorCreateDescription": "Beim Erstellen der Ressource ist ein Fehler aufgetreten", "resourceErrorCreateMessage": "Fehler beim Erstellen der Ressource:", "resourceErrorCreateMessageDescription": "Ein unerwarteter Fehler ist aufgetreten", "sitesErrorFetch": "Fehler beim Abrufen der Standorte", "sitesErrorFetchDescription": "Beim Abrufen der Standorte ist ein Fehler aufgetreten", "domainsErrorFetch": "Fehler beim Abrufen der Domains", "domainsErrorFetchDescription": "Beim Abrufen der Domains ist ein Fehler aufgetreten", "none": "Keine", "unknown": "Unbekannt", "resources": "Ressourcen", "resourcesDescription": "Ressourcen sind Proxies zu Anwendungen, die im privaten Netzwerk ausgeführt werden. Erstellen Sie eine Ressource für jeden HTTP/HTTPS oder rohen TCP/UDP-Dienst in Ihrem privaten Netzwerk. Jede Ressource muss mit einem Standort verbunden sein, um eine private, sichere Verbindung durch einen verschlüsselten WireGuard-Tunnel zu aktivieren.", "resourcesWireGuardConnect": "Sichere Verbindung mit WireGuard-Verschlüsselung", "resourcesMultipleAuthenticationMethods": "Mehrere Authentifizierungsmethoden konfigurieren", "resourcesUsersRolesAccess": "Benutzer- und rollenbasierte Zugriffskontrolle", "resourcesErrorUpdate": "Fehler beim Umschalten der Ressource", "resourcesErrorUpdateDescription": "Beim Aktualisieren der Ressource ist ein Fehler aufgetreten", "access": "Zugriff", "accessControl": "Zugriffskontrolle", "shareLink": "{resource} Freigabe-Link", "resourceSelect": "Ressource auswählen", "shareLinks": "Freigabe-Links", "share": "Teilbare Links", "shareDescription2": "Erstellen Sie teilbare Links zu Ressourcen. Links bieten temporären oder unbegrenzten Zugriff auf Ihre Ressource. Sie können die Verfallsdauer des Links beim Erstellen eines Links festlegen.", "shareEasyCreate": "Einfach zu erstellen und zu teilen", "shareConfigurableExpirationDuration": "Konfigurierbare Ablaufzeit", "shareSecureAndRevocable": "Sicher und widerrufbar", "nameMin": "Der Name muss mindestens {len} Zeichen lang sein.", "nameMax": "Der Name darf nicht länger als {len} Zeichen sein.", "sitesConfirmCopy": "Bitte bestätigen Sie, dass Sie die Konfiguration kopiert haben.", "unknownCommand": "Unbekannter Befehl", "newtErrorFetchReleases": "Fehler beim Abrufen der Release-Informationen: {err}", "newtErrorFetchLatest": "Fehler beim Abrufen der neuesten Version: {err}", "newtEndpoint": "Endpunkt", "newtId": "ID", "newtSecretKey": "Geheimnis", "architecture": "Architektur", "sites": "Standorte", "siteWgAnyClients": "Verwenden Sie jeden WireGuard-Client um sich zu verbinden. Sie müssen interne Ressourcen über die Peer-IP ansprechen.", "siteWgCompatibleAllClients": "Kompatibel mit allen WireGuard-Clients", "siteWgManualConfigurationRequired": "Manuelle Konfiguration erforderlich", "userErrorNotAdminOrOwner": "Benutzer ist kein Administrator oder Eigentümer", "pangolinSettings": "Einstellungen - Pangolin", "accessRoleYour": "Ihre Rolle:", "accessRoleSelect2": "Rollen auswählen", "accessUserSelect": "Benutzer auswählen", "otpEmailEnter": "E-Mail-Adresse eingeben", "otpEmailEnterDescription": "Drücken Sie Enter, um eine E-Mail nach der Eingabe im Eingabefeld hinzuzufügen.", "otpEmailErrorInvalid": "Ungültige E-Mail-Adresse. Platzhalter (*) muss der gesamte lokale Teil sein.", "otpEmailSmtpRequired": "SMTP erforderlich", "otpEmailSmtpRequiredDescription": "SMTP muss auf dem Server aktiviert sein, um die Einmal-Passwort-Authentifizierung zu verwenden.", "otpEmailTitle": "Einmal-Passwörter", "otpEmailTitleDescription": "E-Mail-basierte Authentifizierung für Ressourcenzugriff erforderlich", "otpEmailWhitelist": "E-Mail-Whitelist", "otpEmailWhitelistList": "Zugelassene E-Mails", "otpEmailWhitelistListDescription": "Nur Benutzer mit diesen E-Mail-Adressen können auf diese Ressource zugreifen. Sie werden aufgefordert, ein an ihre E-Mail gesendetes Einmal-Passwort einzugeben. Platzhalter (*@example.com) können verwendet werden, um E-Mail-Adressen einer Domain zuzulassen.", "otpEmailWhitelistSave": "Whitelist speichern", "passwordAdd": "Passwort hinzufügen", "passwordRemove": "Passwort entfernen", "pincodeAdd": "PIN-Code hinzufügen", "pincodeRemove": "PIN-Code entfernen", "resourceAuthMethods": "Authentifizierungsmethoden", "resourceAuthMethodsDescriptions": "Ermöglichen Sie den Zugriff auf die Ressource über zusätzliche Authentifizierungsmethoden", "resourceAuthSettingsSave": "Erfolgreich gespeichert", "resourceAuthSettingsSaveDescription": "Authentifizierungseinstellungen wurden gespeichert", "resourceErrorAuthFetch": "Fehler beim Abrufen der Daten", "resourceErrorAuthFetchDescription": "Beim Abrufen der Daten ist ein Fehler aufgetreten", "resourceErrorPasswordRemove": "Fehler beim Entfernen des Ressourcenpassworts", "resourceErrorPasswordRemoveDescription": "Beim Entfernen des Ressourcenpassworts ist ein Fehler aufgetreten", "resourceErrorPasswordSetup": "Fehler beim Einrichten des Ressourcenpassworts", "resourceErrorPasswordSetupDescription": "Beim Einrichten des Ressourcenpassworts ist ein Fehler aufgetreten", "resourceErrorPincodeRemove": "Fehler beim Entfernen des Ressourcen-PIN-Codes", "resourceErrorPincodeRemoveDescription": "Beim Entfernen des Ressourcen-PIN-Codes ist ein Fehler aufgetreten", "resourceErrorPincodeSetup": "Fehler beim Einrichten des Ressourcen-PIN-Codes", "resourceErrorPincodeSetupDescription": "Beim Einrichten des Ressourcen-PIN-Codes ist ein Fehler aufgetreten", "resourceErrorUsersRolesSave": "Fehler beim Speichern der Rollen", "resourceErrorUsersRolesSaveDescription": "Beim Speichern der Rollen ist ein Fehler aufgetreten", "resourceErrorWhitelistSave": "Fehler beim Speichern der Whitelist", "resourceErrorWhitelistSaveDescription": "Beim Speichern der Whitelist ist ein Fehler aufgetreten", "resourcePasswordSubmit": "Passwortschutz aktivieren", "resourcePasswordProtection": "Passwortschutz {status}", "resourcePasswordRemove": "Ressourcenpasswort entfernt", "resourcePasswordRemoveDescription": "Das Ressourcenpasswort wurde erfolgreich entfernt", "resourcePasswordSetup": "Ressourcenpasswort festgelegt", "resourcePasswordSetupDescription": "Das Ressourcenpasswort wurde erfolgreich festgelegt", "resourcePasswordSetupTitle": "Passwort festlegen", "resourcePasswordSetupTitleDescription": "Legen Sie ein Passwort fest, um diese Ressource zu schützen", "resourcePincode": "PIN-Code", "resourcePincodeSubmit": "PIN-Code-Schutz aktivieren", "resourcePincodeProtection": "PIN-Code-Schutz {status}", "resourcePincodeRemove": "Ressourcen-PIN-Code entfernt", "resourcePincodeRemoveDescription": "Der Ressourcen-PIN-Code wurde erfolgreich entfernt", "resourcePincodeSetup": "Ressourcen-PIN-Code festgelegt", "resourcePincodeSetupDescription": "Der Ressourcen-PIN-Code wurde erfolgreich festgelegt", "resourcePincodeSetupTitle": "PIN-Code festlegen", "resourcePincodeSetupTitleDescription": "Legen Sie einen PIN-Code fest, um diese Ressource zu schützen", "resourceRoleDescription": "Administratoren haben immer Zugriff auf diese Ressource.", "resourceUsersRoles": "Zugriffskontrolle", "resourceUsersRolesDescription": "Konfigurieren Sie, welche Benutzer und Rollen diese Ressource besuchen können", "resourceUsersRolesSubmit": "Zugriffskontrollen speichern", "resourceWhitelistSave": "Erfolgreich gespeichert", "resourceWhitelistSaveDescription": "Whitelist-Einstellungen wurden gespeichert", "ssoUse": "Plattform SSO verwenden", "ssoUseDescription": "Bestehende Benutzer müssen sich nur einmal für alle Ressourcen anmelden, bei denen dies aktiviert ist.", "proxyErrorInvalidPort": "Ungültige Portnummer", "subdomainErrorInvalid": "Ungültige Subdomain", "domainErrorFetch": "Fehler beim Abrufen der Domains", "domainErrorFetchDescription": "Beim Abrufen der Domains ist ein Fehler aufgetreten", "resourceErrorUpdate": "Ressource konnte nicht aktualisiert werden", "resourceErrorUpdateDescription": "Beim Aktualisieren der Ressource ist ein Fehler aufgetreten", "resourceUpdated": "Ressource aktualisiert", "resourceUpdatedDescription": "Die Ressource wurde erfolgreich aktualisiert", "resourceErrorTransfer": "Ressource konnte nicht übertragen werden", "resourceErrorTransferDescription": "Beim Übertragen der Ressource ist ein Fehler aufgetreten", "resourceTransferred": "Ressource übertragen", "resourceTransferredDescription": "Die Ressource wurde erfolgreich übertragen", "resourceErrorToggle": "Ressource konnte nicht umgeschaltet werden", "resourceErrorToggleDescription": "Beim Aktualisieren der Ressource ist ein Fehler aufgetreten", "resourceVisibilityTitle": "Sichtbarkeit", "resourceVisibilityTitleDescription": "Ressourcensichtbarkeit vollständig aktivieren oder deaktivieren", "resourceGeneral": "Allgemeine Einstellungen", "resourceGeneralDescription": "Konfigurieren Sie die allgemeinen Einstellungen für diese Ressource", "resourceEnable": "Ressource aktivieren", "resourceTransfer": "Ressource übertragen", "resourceTransferDescription": "Diese Ressource auf einen anderen Standort übertragen", "resourceTransferSubmit": "Ressource übertragen", "siteDestination": "Zielort", "searchSites": "Standorte durchsuchen", "countries": "Länder", "accessRoleCreate": "Rolle erstellen", "accessRoleCreateDescription": "Erstellen Sie eine neue Rolle, um Benutzer zu gruppieren und ihre Berechtigungen zu verwalten.", "accessRoleEdit": "Rolle bearbeiten", "accessRoleEditDescription": "Rolleninformationen bearbeiten.", "accessRoleCreateSubmit": "Rolle erstellen", "accessRoleCreated": "Rolle erstellt", "accessRoleCreatedDescription": "Die Rolle wurde erfolgreich erstellt.", "accessRoleErrorCreate": "Fehler beim Erstellen der Rolle", "accessRoleErrorCreateDescription": "Beim Erstellen der Rolle ist ein Fehler aufgetreten.", "accessRoleUpdateSubmit": "Rolle aktualisieren", "accessRoleUpdated": "Rolle aktualisiert", "accessRoleUpdatedDescription": "Die Rolle wurde erfolgreich aktualisiert.", "accessApprovalUpdated": "Genehmigung bearbeitet", "accessApprovalApprovedDescription": "Entscheidung für Genehmigungsanfrage setzen.", "accessApprovalDeniedDescription": "Entscheidung für Genehmigungsanfrage ablehnen.", "accessRoleErrorUpdate": "Fehler beim Aktualisieren der Rolle", "accessRoleErrorUpdateDescription": "Beim Aktualisieren der Rolle ist ein Fehler aufgetreten.", "accessApprovalErrorUpdate": "Genehmigung konnte nicht verarbeitet werden", "accessApprovalErrorUpdateDescription": "Bei der Bearbeitung der Genehmigung ist ein Fehler aufgetreten.", "accessRoleErrorNewRequired": "Neue Rolle ist erforderlich", "accessRoleErrorRemove": "Fehler beim Entfernen der Rolle", "accessRoleErrorRemoveDescription": "Beim Entfernen der Rolle ist ein Fehler aufgetreten.", "accessRoleName": "Rollenname", "accessRoleQuestionRemove": "Du bist dabei die Rolle `{name}` zu löschen. Du kannst diese Aktion nicht rückgängig machen.", "accessRoleRemove": "Rolle entfernen", "accessRoleRemoveDescription": "Eine Rolle aus der Organisation entfernen", "accessRoleRemoveSubmit": "Rolle entfernen", "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", "licenseTierProfessional": "Professional Lizenz", "licenseTierEnterprise": "Enterprise Lizenz", "licenseTierPersonal": "Persönliche Lizenz", "licensed": "Lizenziert", "yes": "Ja", "no": "Nein", "sitesAdditional": "Zusätzliche Standorte", "licenseKeys": "Lizenzschlüssel", "sitestCountDecrease": "Anzahl der Standorte verringern", "sitestCountIncrease": "Anzahl der Standorte erhöhen", "idpManage": "Identitätsanbieter verwalten", "idpManageDescription": "Identitätsanbieter im System anzeigen und verwalten", "idpGlobalModeBanner": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt über alle Organisationen). Verwalten Sie globale IdPs im Admin-Panel. Um IdPs pro Organisation zu aktivieren, bearbeiten Sie die Server-Konfiguration und setzen Sie den IdP-Modus auf org. Siehe Dokumentation. Wenn Sie weiterhin globale IdPs verwenden und diese in den Organisationseinstellungen verschwinden lassen wollen, setzen Sie den Modus explizit auf global in der Konfiguration.", "idpGlobalModeBannerUpgradeRequired": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt in allen Organisationen). Globale IdPs im Admin-Panelverwalten. Um Identitätsanbieter pro Organisation nutzen zu können, müssen Sie zur Enterprise Edition upgraden.", "idpGlobalModeBannerLicenseRequired": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt in allen Organisationen). Globale IdPs im Admin-Panelverwalten. Um Identitätsanbieter pro Organisation zu verwenden, ist eine Enterprise-Lizenz erforderlich.", "idpDeletedDescription": "Identitätsanbieter erfolgreich gelöscht", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Sind Sie sicher, dass Sie den Identitätsanbieter dauerhaft löschen möchten?", "idpMessageRemove": "Dies wird den Identitätsanbieter und alle zugehörigen Konfigurationen entfernen. Benutzer, die sich über diesen Anbieter authentifizieren, können sich nicht mehr anmelden.", "idpMessageConfirm": "Bitte geben Sie zur Bestätigung den Namen des Identitätsanbieters unten ein.", "idpConfirmDelete": "Löschen des Identitätsanbieters bestätigen", "idpDelete": "Identitätsanbieter löschen", "idp": "Identitätsanbieter", "idpSearch": "Identitätsanbieter suchen...", "idpAdd": "Identitätsanbieter hinzufügen", "idpClientIdRequired": "Client-ID ist erforderlich.", "idpClientSecretRequired": "Client-Secret ist erforderlich.", "idpErrorAuthUrlInvalid": "Auth-URL muss eine gültige URL sein.", "idpErrorTokenUrlInvalid": "Token-URL muss eine gültige URL sein.", "idpPathRequired": "Identifikationspfad ist erforderlich.", "idpScopeRequired": "Scopes sind erforderlich.", "idpOidcDescription": "Konfigurieren Sie einen OpenID Connect Identitätsanbieter", "idpCreatedDescription": "Identitätsanbieter erfolgreich erstellt", "idpCreate": "Identitätsanbieter erstellen", "idpCreateDescription": "Konfigurieren Sie einen neuen Identitätsanbieter für die Benutzerauthentifizierung", "idpSeeAll": "Alle Identitätsanbieter anzeigen", "idpSettingsDescription": "Konfigurieren Sie die grundlegenden Informationen für Ihren Identitätsanbieter", "idpDisplayName": "Ein Anzeigename für diesen Identitätsanbieter", "idpAutoProvisionUsers": "Automatische Benutzerbereitstellung", "idpAutoProvisionUsersDescription": "Wenn aktiviert, werden Benutzer beim ersten Login automatisch im System erstellt, mit der Möglichkeit, Benutzer Rollen und Organisationen zuzuordnen.", "licenseBadge": "EE", "idpType": "Anbietertyp", "idpTypeDescription": "Wählen Sie den Typ des Identitätsanbieters, den Sie konfigurieren möchten", "idpOidcConfigure": "OAuth2/OIDC Konfiguration", "idpOidcConfigureDescription": "Konfigurieren Sie die OAuth2/OIDC Anbieter-Endpunkte und Anmeldeinformationen", "idpClientId": "Client-ID", "idpClientIdDescription": "Die OAuth2-Client-ID des Identity Providers", "idpClientSecret": "Client-Secret", "idpClientSecretDescription": "Das OAuth2-Client-Geheimnis des Identity Providers", "idpAuthUrl": "Autorisierungs-URL", "idpAuthUrlDescription": "Die OAuth2 Autorisierungs-Endpunkt-URL", "idpTokenUrl": "Token-URL", "idpTokenUrlDescription": "Die OAuth2 Token-Endpunkt-URL", "idpOidcConfigureAlert": "Wichtige Information", "idpOidcConfigureAlertDescription": "Nach der Erstellung des Identity Providers müssen Sie die Callback URL in den Einstellungen des Identity Providers konfigurieren. Die Callback-URL wird nach erfolgreicher Erstellung zur Verfügung gestellt.", "idpToken": "Token-Konfiguration", "idpTokenDescription": "Konfigurieren Sie, wie Benutzerinformationen aus dem ID-Token extrahiert werden", "idpJmespathAbout": "Über JMESPath", "idpJmespathAboutDescription": "Die unten stehenden Pfade verwenden JMESPath-Syntax, um Werte aus dem ID-Token zu extrahieren.", "idpJmespathAboutDescriptionLink": "Mehr über JMESPath erfahren", "idpJmespathLabel": "Identifikationspfad", "idpJmespathLabelDescription": "Der JMESPath zum Benutzeridentifikator im ID-Token", "idpJmespathEmailPathOptional": "E-Mail-Pfad (Optional)", "idpJmespathEmailPathOptionalDescription": "Der JMESPath zur E-Mail-Adresse des Benutzers im ID-Token", "idpJmespathNamePathOptional": "Namenspfad (Optional)", "idpJmespathNamePathOptionalDescription": "Der JMESPath zum Namen des Benutzers im ID-Token", "idpOidcConfigureScopes": "Bereiche", "idpOidcConfigureScopesDescription": "Durch Leerzeichen getrennte Liste der anzufordernden OAuth2-Scopes", "idpSubmit": "Identitätsanbieter erstellen", "orgPolicies": "Organisationsrichtlinien", "idpSettings": "{idpName} Einstellungen", "idpCreateSettingsDescription": "Einstellungen für den Identity Provider konfigurieren", "roleMapping": "Rollenzuordnung", "orgMapping": "Organisationszuordnung", "orgPoliciesSearch": "Organisationsrichtlinien suchen...", "orgPoliciesAdd": "Organisationsrichtlinie hinzufügen", "orgRequired": "Organisation ist erforderlich", "error": "Fehler", "success": "Erfolg", "orgPolicyAddedDescription": "Richtlinie erfolgreich hinzugefügt", "orgPolicyUpdatedDescription": "Richtlinie erfolgreich aktualisiert", "orgPolicyDeletedDescription": "Richtlinie erfolgreich gelöscht", "defaultMappingsUpdatedDescription": "Standardzuordnungen erfolgreich aktualisiert", "orgPoliciesAbout": "Über Organisationsrichtlinien", "orgPoliciesAboutDescription": "Organisationsrichtlinien werden verwendet, um den Zugriff auf Organisationen basierend auf dem ID-Token des Benutzers zu steuern. Sie können JMESPath-Ausdrücke angeben, um Rollen- und Organisationsinformationen aus dem ID-Token zu extrahieren. Weitere Informationen finden Sie in", "orgPoliciesAboutDescriptionLink": "der Dokumentation", "defaultMappingsOptional": "Standardzuordnungen (Optional)", "defaultMappingsOptionalDescription": "Die Standardzuordnungen werden verwendet, wenn keine Organisationsrichtlinie für eine Organisation definiert ist. Sie können hier die Standard-Rollen- und Organisationszuordnungen festlegen.", "defaultMappingsRole": "Standard-Rollenzuordnung", "defaultMappingsRoleDescription": "JMESPath zur Extraktion von Rolleninformationen aus dem ID-Token. Das Ergebnis dieses Ausdrucks muss den Rollennamen als String zurückgeben, wie er in der Organisation definiert ist.", "defaultMappingsOrg": "Standard-Organisationszuordnung", "defaultMappingsOrgDescription": "JMESPath zur Extraktion von Organisationsinformationen aus dem ID-Token. Dieser Ausdruck muss die Organisations-ID oder true zurückgeben, damit der Benutzer Zugriff auf die Organisation erhält.", "defaultMappingsSubmit": "Standardzuordnungen speichern", "orgPoliciesEdit": "Organisationsrichtlinie bearbeiten", "org": "Organisation", "orgSelect": "Organisation auswählen", "orgSearch": "Organisation suchen", "orgNotFound": "Keine Organisation gefunden.", "roleMappingPathOptional": "Rollenzuordnungspfad (Optional)", "orgMappingPathOptional": "Organisationszuordnungspfad (Optional)", "orgPolicyUpdate": "Richtlinie aktualisieren", "orgPolicyAdd": "Richtlinie hinzufügen", "orgPolicyConfig": "Zugriff für eine Organisation konfigurieren", "idpUpdatedDescription": "Identitätsanbieter erfolgreich aktualisiert", "redirectUrl": "Weiterleitungs-URL", "orgIdpRedirectUrls": "Umleitungs-URLs", "redirectUrlAbout": "Über die Weiterleitungs-URL", "redirectUrlAboutDescription": "Dies ist die URL, zu der Benutzer nach der Authentifizierung umgeleitet werden. Sie müssen diese URL in den Einstellungen des Identity Providers konfigurieren.", "pangolinAuth": "Authentifizierung - Pangolin", "verificationCodeLengthRequirements": "Ihr Verifizierungscode muss 8 Zeichen lang sein.", "errorOccurred": "Ein Fehler ist aufgetreten", "emailErrorVerify": "E-Mail konnte nicht verifiziert werden:", "emailVerified": "E-Mail erfolgreich verifiziert! Sie werden weitergeleitet...", "verificationCodeErrorResend": "Verifizierungscode konnte nicht erneut gesendet werden:", "verificationCodeResend": "Verifizierungscode erneut gesendet", "verificationCodeResendDescription": "Wir haben einen neuen Verifizierungscode an Ihre E-Mail-Adresse gesendet. Bitte prüfen Sie Ihren Posteingang.", "emailVerify": "E-Mail verifizieren", "emailVerifyDescription": "Geben Sie den an Ihre E-Mail-Adresse gesendeten Verifizierungscode ein.", "verificationCode": "Verifizierungscode", "verificationCodeEmailSent": "Wir haben einen Verifizierungscode an Ihre E-Mail-Adresse gesendet.", "submit": "Absenden", "emailVerifyResendProgress": "Wird erneut gesendet...", "emailVerifyResend": "Keinen Code erhalten? Hier klicken zum erneuten Senden", "passwordNotMatch": "Passwörter stimmen nicht überein", "signupError": "Beim Registrieren ist ein Fehler aufgetreten", "pangolinLogoAlt": "Pangolin-Logo", "inviteAlready": "Sieht aus, als wären Sie eingeladen worden!", "inviteAlreadyDescription": "Um die Einladung anzunehmen, müssen Sie sich einloggen oder ein Konto erstellen.", "signupQuestion": "Haben Sie bereits ein Konto?", "login": "Anmelden", "resourceNotFound": "Ressource nicht gefunden", "resourceNotFoundDescription": "Die Ressource, auf die Sie zugreifen möchten, existiert nicht.", "pincodeRequirementsLength": "PIN muss genau 6 Ziffern lang sein", "pincodeRequirementsChars": "PIN darf nur Zahlen enthalten", "passwordRequirementsLength": "Passwort muss mindestens 1 Zeichen lang sein", "passwordRequirementsTitle": "Passwortanforderungen:", "passwordRequirementLength": "Mindestens 8 Zeichen lang", "passwordRequirementUppercase": "Mindestens ein Großbuchstabe", "passwordRequirementLowercase": "Mindestens ein Kleinbuchstabe", "passwordRequirementNumber": "Mindestens eine Zahl", "passwordRequirementSpecial": "Mindestens ein Sonderzeichen", "passwordRequirementsMet": "✓ Passwort erfüllt alle Anforderungen", "passwordStrength": "Passwortstärke", "passwordStrengthWeak": "Schwach", "passwordStrengthMedium": "Mittel", "passwordStrengthStrong": "Stark", "passwordRequirements": "Anforderungen:", "passwordRequirementLengthText": "8+ Zeichen", "passwordRequirementUppercaseText": "Großbuchstabe (A-Z)", "passwordRequirementLowercaseText": "Kleinbuchstabe (a-z)", "passwordRequirementNumberText": "Zahl (0-9)", "passwordRequirementSpecialText": "Sonderzeichen (!@#$%...)", "passwordsDoNotMatch": "Passwörter stimmen nicht überein", "otpEmailRequirementsLength": "OTP muss mindestens 1 Zeichen lang sein", "otpEmailSent": "OTP gesendet", "otpEmailSentDescription": "Ein OTP wurde an Ihre E-Mail gesendet", "otpEmailErrorAuthenticate": "Authentifizierung per E-Mail fehlgeschlagen", "pincodeErrorAuthenticate": "Authentifizierung per PIN fehlgeschlagen", "passwordErrorAuthenticate": "Authentifizierung per Passwort fehlgeschlagen", "poweredBy": "Bereitgestellt von", "authenticationRequired": "Authentifizierung erforderlich", "authenticationMethodChoose": "Wählen Sie Ihre bevorzugte Methode für den Zugriff auf {name}", "authenticationRequest": "Sie müssen sich authentifizieren, um auf {name} zuzugreifen", "user": "Benutzer", "pincodeInput": "6-stelliger PIN-Code", "pincodeSubmit": "Mit PIN anmelden", "passwordSubmit": "Mit Passwort anmelden", "otpEmailDescription": "Ein Einmalcode wird an diese E-Mail gesendet.", "otpEmailSend": "Einmalcode senden", "otpEmail": "Einmalpasswort (OTP)", "otpEmailSubmit": "OTP absenden", "backToEmail": "Zurück zur E-Mail", "noSupportKey": "Server läuft ohne Unterstützungsschlüssel. Erwägen Sie die Unterstützung des Projekts!", "accessDenied": "Zugriff verweigert", "accessDeniedDescription": "Sie haben keine Berechtigung, auf diese Ressource zuzugreifen. Falls dies ein Fehler ist, kontaktieren Sie bitte den Administrator.", "accessTokenError": "Fehler beim Prüfen des Zugriffstokens", "accessGranted": "Zugriff gewährt", "accessUrlInvalid": "Zugriffs-URL ungültig", "accessGrantedDescription": "Ihnen wurde Zugriff auf diese Ressource gewährt. Sie werden weitergeleitet...", "accessUrlInvalidDescription": "Diese geteilte Zugriffs-URL ist ungültig. Bitte kontaktieren Sie den Ressourceneigentümer für eine neue URL.", "tokenInvalid": "Ungültiger Token", "pincodeInvalid": "Ungültiger Code", "passwordErrorRequestReset": "Zurücksetzung konnte nicht angefordert werden:", "passwordErrorReset": "Passwort konnte nicht zurückgesetzt werden:", "passwordResetSuccess": "Passwort erfolgreich zurückgesetzt! Zurück zur Anmeldung...", "passwordReset": "Passwort zurücksetzen", "passwordResetDescription": "Folgen Sie den Schritten, um Ihr Passwort zurückzusetzen", "passwordResetSent": "Wir senden einen Code zum Zurücksetzen des Passworts an diese E-Mail-Adresse.", "passwordResetCode": "Reset-Code", "passwordResetCodeDescription": "Prüfen Sie Ihre E-Mail für den Reset-Code.", "generatePasswordResetCode": "Passwort zurücksetzen Code generieren", "passwordResetCodeGenerated": "Passwort zurücksetzen Code generiert", "passwordResetCodeGeneratedDescription": "Teilen Sie diesen Code mit dem Benutzer. Sie können ihn verwenden, um ihr Passwort zurückzusetzen.", "passwordResetUrl": "Reset URL", "passwordNew": "Neues Passwort", "passwordNewConfirm": "Neues Passwort bestätigen", "changePassword": "Passwort ändern", "changePasswordDescription": "Passwort des Kontos aktualisieren", "oldPassword": "Aktuelles Passwort", "newPassword": "Neues Passwort", "confirmNewPassword": "Neues Passwort bestätigen", "changePasswordError": "Fehler beim Ändern des Passworts", "changePasswordErrorDescription": "Fehler beim Ändern Ihres Passworts", "changePasswordSuccess": "Passwort erfolgreich geändert", "changePasswordSuccessDescription": "Ihr Passwort wurde erfolgreich aktualisiert", "passwordExpiryRequired": "Passwortablauf erforderlich", "passwordExpiryDescription": "Diese Organisation erfordert, dass Sie Ihr Passwort alle {maxDays} Tage ändern.", "changePasswordNow": "Passwort jetzt ändern", "pincodeAuth": "Authentifizierungscode", "pincodeSubmit2": "Code einreichen", "passwordResetSubmit": "Zurücksetzung anfordern", "passwordResetAlreadyHaveCode": "Code eingeben", "passwordResetSmtpRequired": "Bitte kontaktieren Sie Ihren Administrator", "passwordResetSmtpRequiredDescription": "Zum Zurücksetzen Ihres Passworts ist ein Passwort erforderlich. Bitte wenden Sie sich an Ihren Administrator.", "passwordBack": "Zurück zum Passwort", "loginBack": "Zurück zur Haupt-Login-Seite", "signup": "Registrieren", "loginStart": "Melden Sie sich an, um zu beginnen", "idpOidcTokenValidating": "OIDC-Token wird validiert", "idpOidcTokenResponse": "OIDC-Token-Antwort validieren", "idpErrorOidcTokenValidating": "Fehler beim Validieren des OIDC-Tokens", "idpConnectingTo": "Verbindung zu {name} wird hergestellt", "idpConnectingToDescription": "Ihre Identität wird überprüft", "idpConnectingToProcess": "Verbindung wird hergestellt...", "idpConnectingToFinished": "Verbunden", "idpErrorConnectingTo": "Es gab ein Problem bei der Verbindung zu {name}. Bitte kontaktieren Sie Ihren Administrator.", "idpErrorNotFound": "IdP nicht gefunden", "inviteInvalid": "Ungültige Einladung", "inviteInvalidDescription": "Der Einladungslink ist ungültig.", "inviteErrorWrongUser": "Einladung ist nicht für diesen Benutzer", "inviteErrorUserNotExists": "Benutzer existiert nicht. Bitte erstelle zuerst ein Konto.", "inviteErrorLoginRequired": "Du musst angemeldet sein, um eine Einladung anzunehmen", "inviteErrorExpired": "Die Einladung ist möglicherweise abgelaufen", "inviteErrorRevoked": "Die Einladung wurde möglicherweise widerrufen", "inviteErrorTypo": "Es könnte ein Tippfehler im Einladungslink sein", "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.", "overview": "Übersicht", "home": "Startseite", "settings": "Einstellungen", "usersAll": "Alle Benutzer", "license": "Lizenz", "pangolinDashboard": "Dashboard - Pangolin", "noResults": "Keine Ergebnisse gefunden.", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "Eingegebene Tags", "tagsEnteredDescription": "Dies sind die von Ihnen eingegebenen Tags.", "tagsWarnCannotBeLessThanZero": "maxTags und minTags können nicht kleiner als 0 sein", "tagsWarnNotAllowedAutocompleteOptions": "Tag ist laut Autovervollständigungsoptionen nicht erlaubt", "tagsWarnInvalid": "Ungültiger Tag laut validateTag", "tagWarnTooShort": "Tag {tagText} ist zu kurz", "tagWarnTooLong": "Tag {tagText} ist zu lang", "tagsWarnReachedMaxNumber": "Maximale Anzahl erlaubter Tags erreicht", "tagWarnDuplicate": "Doppelter Tag {tagText} nicht hinzugefügt", "supportKeyInvalid": "Ungültiger Schlüssel", "supportKeyInvalidDescription": "Ihr Unterstützer-Schlüssel ist ungültig.", "supportKeyValid": "Gültiger Schlüssel", "supportKeyValidDescription": "Ihr Unterstützer-Schlüssel wurde validiert. Danke für Ihre Unterstützung!", "supportKeyErrorValidationDescription": "Unterstützer-Schlüssel konnte nicht validiert werden.", "supportKey": "Unterstütze die Entwicklung und adoptiere ein Pangolin!", "supportKeyDescription": "Kaufen Sie einen Unterstützer-Schlüssel, um uns bei der Weiterentwicklung von Pangolin für die Community zu helfen. Ihr Beitrag ermöglicht es uns, mehr Zeit in die Wartung und neue Funktionen für alle zu investieren. Wir werden dies nie für Paywalls nutzen. Dies ist unabhängig von der Commercial Edition.", "supportKeyPet": "Sie können auch Ihr eigenes Pangolin-Haustier adoptieren und kennenlernen!", "supportKeyPurchase": "Zahlungen werden über GitHub abgewickelt. Danach können Sie Ihren Schlüssel auf", "supportKeyPurchaseLink": "Unserer Website", "supportKeyPurchase2": "abrufen und hier einlösen.", "supportKeyLearnMore": "Mehr erfahren.", "supportKeyOptions": "Bitte wählen Sie die Option, die am besten zu Ihnen passt.", "supportKetOptionFull": "Voller Unterstützer", "forWholeServer": "Für den gesamten Server", "lifetimePurchase": "Lebenslanger Kauf", "supporterStatus": "Unterstützer-Status", "buy": "Kaufen", "supportKeyOptionLimited": "Eingeschränkter Unterstützer", "forFiveUsers": "Für 5 oder weniger Benutzer", "supportKeyRedeem": "Unterstützer-Schlüssel einlösen", "supportKeyHideSevenDays": "7 Tage ausblenden", "supportKeyEnter": "Unterstützer-Schlüssel eingeben", "supportKeyEnterDescription": "Treffen Sie Ihr eigenes Pangolin-Haustier!", "githubUsername": "GitHub Benutzername", "supportKeyInput": "Unterstützer-Schlüssel", "supportKeyBuy": "Unterstützer-Schlüssel kaufen", "logoutError": "Fehler beim Abmelden", "signingAs": "Angemeldet als", "serverAdmin": "Server-Administrator", "managedSelfhosted": "Verwaltetes Selbsthosted", "otpEnable": "Zwei-Faktor aktivieren", "otpDisable": "Zwei-Faktor deaktivieren", "logout": "Abmelden", "licenseTierProfessionalRequired": "Professional Edition erforderlich", "licenseTierProfessionalRequiredDescription": "Diese Funktion ist nur in der Professional Edition verfügbar.", "actionGetOrg": "Organisation abrufen", "updateOrgUser": "Org Benutzer aktualisieren", "createOrgUser": "Org Benutzer erstellen", "actionUpdateOrg": "Organisation aktualisieren", "actionRemoveInvitation": "Einladung entfernen", "actionUpdateUser": "Benutzer aktualisieren", "actionGetUser": "Benutzer abrufen", "actionGetOrgUser": "Organisationsbenutzer abrufen", "actionListOrgDomains": "Organisationsdomains auflisten", "actionGetDomain": "Domain abrufen", "actionCreateOrgDomain": "Domain erstellen", "actionUpdateOrgDomain": "Domain aktualisieren", "actionDeleteOrgDomain": "Domain löschen", "actionGetDNSRecords": "DNS-Einträge abrufen", "actionRestartOrgDomain": "Domain neu starten", "actionCreateSite": "Standort erstellen", "actionDeleteSite": "Standort löschen", "actionGetSite": "Standort abrufen", "actionListSites": "Standorte auflisten", "actionApplyBlueprint": "Blueprint anwenden", "actionListBlueprints": "Blaupausen anzeigen", "actionGetBlueprint": "Erhalte Blaupause", "setupToken": "Setup-Token", "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenRequired": "Setup-Token ist erforderlich", "actionUpdateSite": "Standorte aktualisieren", "actionListSiteRoles": "Erlaubte Standort-Rollen auflisten", "actionCreateResource": "Ressource erstellen", "actionDeleteResource": "Ressource löschen", "actionGetResource": "Ressource abrufen", "actionListResource": "Ressourcen auflisten", "actionUpdateResource": "Ressource aktualisieren", "actionListResourceUsers": "Ressourcenbenutzer auflisten", "actionSetResourceUsers": "Ressourcenbenutzer festlegen", "actionSetAllowedResourceRoles": "Erlaubte Ressourcenrollen festlegen", "actionListAllowedResourceRoles": "Erlaubte Ressourcenrollen auflisten", "actionSetResourcePassword": "Ressourcenpasswort festlegen", "actionSetResourcePincode": "Ressourcen-PIN festlegen", "actionSetResourceEmailWhitelist": "Ressourcen-E-Mail-Whitelist festlegen", "actionGetResourceEmailWhitelist": "Ressourcen-E-Mail-Whitelist abrufen", "actionCreateTarget": "Ziel erstellen", "actionDeleteTarget": "Ziel löschen", "actionGetTarget": "Ziel abrufen", "actionListTargets": "Ziele auflisten", "actionUpdateTarget": "Ziel aktualisieren", "actionCreateRole": "Rolle erstellen", "actionDeleteRole": "Rolle löschen", "actionGetRole": "Rolle abrufen", "actionListRole": "Rollen auflisten", "actionUpdateRole": "Rolle aktualisieren", "actionListAllowedRoleResources": "Erlaubte Rollenressourcen auflisten", "actionInviteUser": "Benutzer einladen", "actionRemoveUser": "Benutzer entfernen", "actionListUsers": "Benutzer auflisten", "actionAddUserRole": "Benutzerrolle hinzufügen", "actionGenerateAccessToken": "Zugriffstoken generieren", "actionDeleteAccessToken": "Zugriffstoken löschen", "actionListAccessTokens": "Zugriffstoken auflisten", "actionCreateResourceRule": "Ressourcenregel erstellen", "actionDeleteResourceRule": "Ressourcenregel löschen", "actionListResourceRules": "Ressourcenregeln auflisten", "actionUpdateResourceRule": "Ressourcenregel aktualisieren", "actionListOrgs": "Organisationen auflisten", "actionCheckOrgId": "ID prüfen", "actionCreateOrg": "Organisation erstellen", "actionDeleteOrg": "Organisation löschen", "actionListApiKeys": "API-Schlüssel auflisten", "actionListApiKeyActions": "API-Schlüsselaktionen auflisten", "actionSetApiKeyActions": "Erlaubte API-Schlüsselaktionen festlegen", "actionCreateApiKey": "API-Schlüssel erstellen", "actionDeleteApiKey": "API-Schlüssel löschen", "actionCreateIdp": "IDP erstellen", "actionUpdateIdp": "IDP aktualisieren", "actionDeleteIdp": "IDP löschen", "actionListIdps": "IDP auflisten", "actionGetIdp": "IDP abrufen", "actionCreateIdpOrg": "IDP-Organisationsrichtlinie erstellen", "actionDeleteIdpOrg": "IDP-Organisationsrichtlinie löschen", "actionListIdpOrgs": "IDP-Organisationen auflisten", "actionUpdateIdpOrg": "IDP-Organisation aktualisieren", "actionCreateClient": "Client erstellen", "actionDeleteClient": "Client löschen", "actionArchiveClient": "Client archivieren", "actionUnarchiveClient": "Client dearchivieren", "actionBlockClient": "Client sperren", "actionUnblockClient": "Client entsperren", "actionUpdateClient": "Client aktualisieren", "actionListClients": "Clients auflisten", "actionGetClient": "Clients abrufen", "actionCreateSiteResource": "Standort Ressource erstellen", "actionDeleteSiteResource": "Standort Ressource löschen", "actionGetSiteResource": "Standort Ressource abrufen", "actionListSiteResources": "Standort Ressource auflisten", "actionUpdateSiteResource": "Standort Ressource aktualisieren", "actionListInvitations": "Einladungen auflisten", "actionExportLogs": "Logs exportieren", "actionViewLogs": "Logs anzeigen", "noneSelected": "Keine ausgewählt", "orgNotFound2": "Keine Organisationen gefunden.", "searchPlaceholder": "Suche...", "emptySearchOptions": "Keine Optionen gefunden", "create": "Erstellen", "orgs": "Organisationen", "loginError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", "loginRequiredForDevice": "Anmeldung ist für Ihr Gerät erforderlich.", "passwordForgot": "Passwort vergessen?", "otpAuth": "Zwei-Faktor-Authentifizierung", "otpAuthDescription": "Geben Sie den Code aus Ihrer Authenticator-App oder einen Ihrer einmaligen Backup-Codes ein.", "otpAuthSubmit": "Code absenden", "idpContinue": "Oder weiter mit", "otpAuthBack": "Zurück zum Passwort", "navbar": "Navigationsmenü", "navbarDescription": "Hauptnavigationsmenü für die Anwendung", "navbarDocsLink": "Dokumentation", "otpErrorEnable": "2FA konnte nicht aktiviert werden", "otpErrorEnableDescription": "Beim Aktivieren der 2FA ist ein Fehler aufgetreten", "otpSetupCheckCode": "Bitte geben Sie einen 6-stelligen Code ein", "otpSetupCheckCodeRetry": "Ungültiger Code. Bitte versuchen Sie es erneut.", "otpSetup": "Zwei-Faktor-Authentifizierung aktivieren", "otpSetupDescription": "Sichern Sie Ihr Konto mit einer zusätzlichen Schutzebene", "otpSetupScanQr": "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App oder geben Sie den Geheimschlüssel manuell ein:", "otpSetupSecretCode": "Authenticator-Code", "otpSetupSuccess": "Zwei-Faktor-Authentifizierung aktiviert", "otpSetupSuccessStoreBackupCodes": "Ihr Konto ist jetzt sicherer. Vergessen Sie nicht, Ihre Backup-Codes zu speichern.", "otpErrorDisable": "2FA konnte nicht deaktiviert werden", "otpErrorDisableDescription": "Beim Deaktivieren der 2FA ist ein Fehler aufgetreten", "otpRemove": "Zwei-Faktor-Authentifizierung deaktivieren", "otpRemoveDescription": "Deaktivieren Sie die Zwei-Faktor-Authentifizierung für Ihr Konto", "otpRemoveSuccess": "Zwei-Faktor-Authentifizierung deaktiviert", "otpRemoveSuccessMessage": "Die Zwei-Faktor-Authentifizierung wurde für Ihr Konto deaktiviert. Sie können sie jederzeit wieder aktivieren.", "otpRemoveSubmit": "2FA deaktivieren", "paginator": "Seite {current} von {last}", "paginatorToFirst": "Zur ersten Seite", "paginatorToPrevious": "Zur vorherigen Seite", "paginatorToNext": "Zur nächsten Seite", "paginatorToLast": "Zur letzten Seite", "copyText": "Text kopieren", "copyTextFailed": "Text konnte nicht kopiert werden: ", "copyTextClipboard": "In die Zwischenablage kopieren", "inviteErrorInvalidConfirmation": "Ungültige Bestätigung", "passwordRequired": "Passwort ist erforderlich", "allowAll": "Alle erlauben", "permissionsAllowAll": "Alle Berechtigungen erlauben", "githubUsernameRequired": "GitHub-Benutzername ist erforderlich", "supportKeyRequired": "Unterstützer-Schlüssel ist erforderlich", "passwordRequirementsChars": "Das Passwort muss mindestens 8 Zeichen lang sein", "language": "Sprache", "verificationCodeRequired": "Code ist erforderlich", "userErrorNoUpdate": "Kein Benutzer zum Aktualisieren", "siteErrorNoUpdate": "Keine Standorte zum Aktualisieren", "resourceErrorNoUpdate": "Keine Ressource zum Aktualisieren", "authErrorNoUpdate": "Keine Auth-Informationen zum Aktualisieren", "orgErrorNoUpdate": "Keine Organisation zum Aktualisieren", "orgErrorNoProvided": "Keine Organisation angegeben", "apiKeysErrorNoUpdate": "Kein API-Schlüssel zum Aktualisieren", "sidebarOverview": "Übersicht", "sidebarHome": "Zuhause", "sidebarSites": "Standorte", "sidebarApprovals": "Genehmigungsanfragen", "sidebarResources": "Ressourcen", "sidebarProxyResources": "Öffentlich", "sidebarClientResources": "Privat", "sidebarAccessControl": "Zugriffskontrolle", "sidebarLogsAndAnalytics": "Protokolle & Analysen", "sidebarTeam": "Team", "sidebarUsers": "Benutzer", "sidebarAdmin": "Admin", "sidebarInvitations": "Einladungen", "sidebarRoles": "Rollen", "sidebarShareableLinks": "Links", "sidebarApiKeys": "API-Schlüssel", "sidebarSettings": "Einstellungen", "sidebarAllUsers": "Alle Benutzer", "sidebarIdentityProviders": "Identitätsanbieter", "sidebarLicense": "Lizenz", "sidebarClients": "Clients", "sidebarUserDevices": "Benutzer-Geräte", "sidebarMachineClients": "Maschinen", "sidebarDomains": "Domänen", "sidebarGeneral": "Verwalten", "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", "blueprintAdd": "Blueprint hinzufügen", "blueprintGoBack": "Alle Blueprints ansehen", "blueprintCreate": "Blueprint erstellen", "blueprintCreateDescription2": "Folge den unten aufgeführten Schritten, um einen neuen Blueprint zu erstellen und anzuwenden", "blueprintDetails": "Blueprint Detailinformationen", "blueprintDetailsDescription": "Siehe das Ergebnis des angewendeten Blueprints und alle aufgetretenen Fehler", "blueprintInfo": "Blueprint Informationen", "message": "Nachricht", "blueprintContentsDescription": "Den YAML-Inhalt definieren, der die Infrastruktur beschreibt", "blueprintErrorCreateDescription": "Fehler beim Anwenden des Blueprints", "blueprintErrorCreate": "Fehler beim Erstellen des Blueprints", "searchBlueprintProgress": "Blueprints suchen...", "appliedAt": "Angewandt am", "source": "Quelle", "contents": "Inhalt", "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.", "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.", "containerName": "Name", "containerImage": "Image", "containerState": "Status", "containerNetworks": "Netzwerke", "containerHostnameIp": "Hostname/IP", "containerLabels": "Etiketten", "containerLabelsCount": "{count, plural, one {# Etikett} other {# Etiketten}}", "containerLabelsTitle": "Container-Labels", "containerLabelEmpty": "", "containerPorts": "Ports", "containerPortsMore": "+{count} mehr", "containerActions": "Aktionen", "select": "Auswählen", "noContainersMatchingFilters": "Es wurden keine Container gefunden, die den aktuellen Filtern entsprechen.", "showContainersWithoutPorts": "Container ohne Ports anzeigen", "showStoppedContainers": "Stoppte Container anzeigen", "noContainersFound": "Keine Container gefunden. Stellen Sie sicher, dass Docker Container laufen.", "searchContainersPlaceholder": "Durchsuche {count} Container...", "searchResultsCount": "{count, plural, one {# Ergebnis} other {# Ergebnisse}}", "filters": "Filter", "filterOptions": "Filteroptionen", "filterPorts": "Ports", "filterStopped": "Stoppt", "clearAllFilters": "Alle Filter löschen", "columns": "Spalten", "toggleColumns": "Spalten umschalten", "refreshContainersList": "Container-Liste aktualisieren", "searching": "Suche...", "noContainersFoundMatching": "Keine Container gefunden mit \"{filter}\".", "light": "hell", "dark": "dunkel", "system": "System", "theme": "Design", "subnetRequired": "Subnetz ist erforderlich", "initialSetupTitle": "Initial Einrichtung des Servers", "initialSetupDescription": "Erstellen Sie das initiale Server-Admin-Konto. Es kann nur einen Server-Admin geben. Sie können diese Anmeldedaten später immer ändern.", "createAdminAccount": "Admin-Konto erstellen", "setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.", "certificateStatus": "Zertifikatsstatus", "loading": "Laden", "loadingAnalytics": "Analytik wird geladen", "restart": "Neustart", "domains": "Domänen", "domainsDescription": "Erstellen und verwalten der in der Organisation verfügbaren Domänen", "domainsSearch": "Domains durchsuchen...", "domainAdd": "Domain hinzufügen", "domainAddDescription": "Registrieren Sie eine neue Domäne mit der Organisation", "domainCreate": "Domain erstellen", "domainCreatedDescription": "Domain erfolgreich erstellt", "domainDeletedDescription": "Domain erfolgreich gelöscht", "domainQuestionRemove": "Sind Sie sicher, dass Sie die Domain entfernen möchten?", "domainMessageRemove": "Nach der Entfernung wird die Domain nicht mehr mit der Organisation verknüpft.", "domainConfirmDelete": "Domain-Löschung bestätigen", "domainDelete": "Domain löschen", "domain": "Domäne", "selectDomainTypeNsName": "Domain-Delegation (NS)", "selectDomainTypeNsDescription": "Diese Domain und alle ihre Subdomains. Verwenden Sie dies, wenn Sie eine gesamte Domainzone kontrollieren möchten.", "selectDomainTypeCnameName": "Einzelne Domain (CNAME)", "selectDomainTypeCnameDescription": "Nur diese spezifische Domain. Verwenden Sie dies für einzelne Subdomains oder spezifische Domaineinträge.", "selectDomainTypeWildcardName": "Wildcard-Domain", "selectDomainTypeWildcardDescription": "Diese Domain und ihre Subdomains.", "domainDelegation": "Einzelne Domain", "selectType": "Typ auswählen", "actions": "Aktionen", "refresh": "Aktualisieren", "refreshError": "Datenaktualisierung fehlgeschlagen", "verified": "Verifiziert", "pending": "Ausstehend", "pendingApproval": "Ausstehende Genehmigung", "sidebarBilling": "Abrechnung", "billing": "Abrechnung", "orgBillingDescription": "Zahlungsinformationen und Abonnements verwalten", "github": "GitHub", "pangolinHosted": "Pangolin Hosted", "fossorial": "Fossorial", "completeAccountSetup": "Kontoeinrichtung abschließen", "completeAccountSetupDescription": "Legen Sie Ihr Passwort fest, um zu beginnen", "accountSetupSent": "Wir senden einen Code für die Kontoeinrichtung an diese E-Mail-Adresse.", "accountSetupCode": "Einrichtungscode", "accountSetupCodeDescription": "Prüfen Sie Ihre E-Mail auf den Einrichtungscode.", "passwordCreate": "Passwort erstellen", "passwordCreateConfirm": "Passwort bestätigen", "accountSetupSubmit": "Einrichtungscode senden", "completeSetup": "Einrichtung abschließen", "accountSetupSuccess": "Kontoeinrichtung abgeschlossen! Willkommen bei Pangolin!", "documentation": "Dokumentation", "saveAllSettings": "Alle Einstellungen speichern", "saveResourceTargets": "Ziele speichern", "saveResourceHttp": "Proxy-Einstellungen speichern", "saveProxyProtocol": "Proxy-Protokolleinstellungen speichern", "settingsUpdated": "Einstellungen aktualisiert", "settingsUpdatedDescription": "Einstellungen erfolgreich aktualisiert", "settingsErrorUpdate": "Einstellungen konnten nicht aktualisiert werden", "settingsErrorUpdateDescription": "Beim Aktualisieren der Einstellungen ist ein Fehler aufgetreten", "sidebarCollapse": "Zusammenklappen", "sidebarExpand": "Aufklappen", "productUpdateMoreInfo": "{noOfUpdates} weitere Updates", "productUpdateInfo": "{noOfUpdates} Updates", "productUpdateWhatsNew": "Was ist neu", "productUpdateTitle": "Produkt-Updates", "productUpdateEmpty": "Keine Updates", "dismissAll": "Alle verwerfen", "pangolinUpdateAvailable": "Update verfügbar", "pangolinUpdateAvailableInfo": "Version {version} ist bereit zur Installation", "pangolinUpdateAvailableReleaseNotes": "Versionshinweise anzeigen", "newtUpdateAvailable": "Update verfügbar", "newtUpdateAvailableInfo": "Eine neue Version von Newt ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", "domainPickerEnterDomain": "Domäne", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Geben Sie die vollständige Domain der Ressource ein, um verfügbare Optionen zu sehen.", "domainPickerDescriptionSaas": "Geben Sie eine vollständige Domain, Subdomain oder einfach einen Namen ein, um verfügbare Optionen zu sehen", "domainPickerTabAll": "Alle", "domainPickerTabOrganization": "Organisation", "domainPickerTabProvided": "Bereitgestellt", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Verfügbarkeit prüfen...", "domainPickerNoMatchingDomains": "Keine passenden Domains gefunden. Versuchen Sie eine andere Domain oder überprüfen Sie die Domain-Einstellungen der Organisation.", "domainPickerOrganizationDomains": "Organisations-Domains", "domainPickerProvidedDomains": "Bereitgestellte Domains", "domainPickerSubdomain": "Subdomain: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mehr anzeigen", "regionSelectorTitle": "Region auswählen", "regionSelectorInfo": "Das Auswählen einer Region hilft uns, eine bessere Leistung für Ihren Standort bereitzustellen. Sie müssen sich nicht in derselben Region wie Ihr Server befinden.", "regionSelectorPlaceholder": "Wähle eine Region", "regionSelectorComingSoon": "Kommt bald", "billingLoadingSubscription": "Abonnement wird geladen...", "billingFreeTier": "Kostenlose Stufe", "billingWarningOverLimit": "Warnung: Sie haben ein oder mehrere Nutzungslimits überschritten. Ihre Webseiten werden nicht verbunden, bis Sie Ihr Abonnement ändern oder Ihren Verbrauch anpassen.", "billingUsageLimitsOverview": "Übersicht über Nutzungsgrenzen", "billingMonitorUsage": "Überwachen Sie Ihren Verbrauch im Vergleich zu konfigurierten Grenzwerten. Wenn Sie eine Erhöhung der Limits benötigen, kontaktieren Sie uns bitte support@pangolin.net.", "billingDataUsage": "Datenverbrauch", "billingSites": "Seiten", "billingUsers": "Benutzergeräte", "billingDomains": "Domänen", "billingOrganizations": "Orden", "billingRemoteExitNodes": "Entfernte Knoten", "billingNoLimitConfigured": "Kein Limit konfiguriert", "billingEstimatedPeriod": "Geschätzter Abrechnungszeitraum", "billingIncludedUsage": "Inklusive Nutzung", "billingIncludedUsageDescription": "Nutzung, die in Ihrem aktuellen Abonnementplan enthalten ist", "billingFreeTierIncludedUsage": "Nutzungskontingente der kostenlosen Stufe", "billingIncluded": "inbegriffen", "billingEstimatedTotal": "Geschätzte Gesamtsumme:", "billingNotes": "Notizen", "billingEstimateNote": "Dies ist eine Schätzung basierend auf Ihrem aktuellen Verbrauch.", "billingActualChargesMayVary": "Tatsächliche Kosten können variieren.", "billingBilledAtEnd": "Sie werden am Ende des Abrechnungszeitraums in Rechnung gestellt.", "billingModifySubscription": "Abonnement ändern", "billingStartSubscription": "Abonnement starten", "billingRecurringCharge": "Wiederkehrende Kosten", "billingManageSubscriptionSettings": "Abonnementeinstellungen und -einstellungen verwalten", "billingNoActiveSubscription": "Sie haben kein aktives Abonnement. Starten Sie Ihr Abonnement, um Nutzungslimits zu erhöhen.", "billingFailedToLoadSubscription": "Fehler beim Laden des Abonnements", "billingFailedToLoadUsage": "Fehler beim Laden der Nutzung", "billingFailedToGetCheckoutUrl": "Fehler beim Abrufen der Checkout-URL", "billingPleaseTryAgainLater": "Bitte versuchen Sie es später noch einmal.", "billingCheckoutError": "Checkout-Fehler", "billingFailedToGetPortalUrl": "Fehler beim Abrufen der Portal-URL", "billingPortalError": "Portalfehler", "billingDataUsageInfo": "Wenn Sie mit der Cloud verbunden sind, werden alle Daten über Ihre sicheren Tunnel belastet. Dies schließt eingehenden und ausgehenden Datenverkehr über alle Ihre Websites ein. Wenn Sie Ihr Limit erreichen, werden Ihre Seiten die Verbindung trennen, bis Sie Ihr Paket upgraden oder die Nutzung verringern. Daten werden nicht belastet, wenn Sie Knoten verwenden.", "billingSInfo": "Anzahl der Sites die Sie verwenden können", "billingUsersInfo": "Wie viele Benutzer Sie verwenden können", "billingDomainInfo": "Wie viele Domains Sie verwenden können", "billingRemoteExitNodesInfo": "Wie viele entfernte Knoten Sie verwenden können", "billingLicenseKeys": "Lizenzschlüssel", "billingLicenseKeysDescription": "Verwalten Sie Ihre Lizenzschlüssel Abonnements", "billingLicenseSubscription": "Lizenzabonnement", "billingInactive": "Inaktiv", "billingLicenseItem": "Lizenz-Element", "billingQuantity": "Menge", "billingTotal": "gesamt", "billingModifyLicenses": "Lizenzabonnement ändern", "domainNotFound": "Domain nicht gefunden", "domainNotFoundDescription": "Diese Ressource ist deaktiviert, weil die Domain nicht mehr in unserem System existiert. Bitte setzen Sie eine neue Domain für diese Ressource.", "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", "securityKeyRegister": "Neuen Sicherheitsschlüssel registrieren", "securityKeyList": "Ihre Sicherheitsschlüssel", "securityKeyNone": "Noch keine Sicherheitsschlüssel registriert", "securityKeyNameRequired": "Name ist erforderlich", "securityKeyRemove": "Entfernen", "securityKeyLastUsed": "Zuletzt verwendet: {date}", "securityKeyNameLabel": "Name", "securityKeyRegisterSuccess": "Sicherheitsschlüssel erfolgreich registriert", "securityKeyRegisterError": "Fehler beim Registrieren des Sicherheitsschlüssels", "securityKeyRemoveSuccess": "Sicherheitsschlüssel erfolgreich entfernt", "securityKeyRemoveError": "Fehler beim Entfernen des Sicherheitsschlüssels", "securityKeyLoadError": "Fehler beim Laden der Sicherheitsschlüssel", "securityKeyLogin": "Sicherheitsschlüssel verwenden", "securityKeyAuthError": "Fehler bei der Authentifizierung mit Sicherheitsschlüssel", "securityKeyRecommendation": "Erwägen Sie die Registrierung eines weiteren Sicherheitsschlüssels auf einem anderen Gerät, um sicherzustellen, dass Sie sich nicht aus Ihrem Konto aussperren.", "registering": "Registrierung...", "securityKeyPrompt": "Bitte bestätigen Sie Ihre Identität mit Ihrem Sicherheitsschlüssel. Stellen Sie sicher, dass Ihr Sicherheitsschlüssel verbunden und einsatzbereit ist.", "securityKeyBrowserNotSupported": "Ihr Browser unterstützt Sicherheitsschlüssel nicht. Bitte verwenden Sie einen modernen Browser wie Chrome, Firefox oder Safari.", "securityKeyPermissionDenied": "Bitte erlauben Sie den Zugriff auf Ihren Sicherheitsschlüssel, um sich weiter anzumelden.", "securityKeyRemovedTooQuickly": "Lassen Sie Ihren Sicherheitsschlüssel verbunden, bis der Anmeldeprozess abgeschlossen ist.", "securityKeyNotSupported": "Ihr Sicherheitsschlüssel ist möglicherweise nicht kompatibel. Bitte versuchen Sie einen anderen Sicherheitsschlüssel.", "securityKeyUnknownError": "Es gab ein Problem mit Ihrem Sicherheitsschlüssel. Bitte versuchen Sie es erneut.", "twoFactorRequired": "Zur Registrierung eines Sicherheitsschlüssels ist eine Zwei-Faktor-Authentifizierung erforderlich.", "twoFactor": "Zwei-Faktor-Authentifizierung", "twoFactorAuthentication": "Zwei-Faktor-Authentifizierung", "twoFactorDescription": "Diese Organisation erfordert Zwei-Faktor-Authentifizierung.", "enableTwoFactor": "Zwei-Faktor-Authentifizierung aktivieren", "organizationSecurityPolicy": "Sicherheitsrichtlinien der Organisation", "organizationSecurityPolicyDescription": "Diese Organisation hat Sicherheitsanforderungen, die erfüllt werden müssen, bevor Sie darauf zugreifen können", "securityRequirements": "Sicherheitsanforderungen", "allRequirementsMet": "Alle Anforderungen wurden erfüllt", "completeRequirementsToContinue": "Erfülle die folgenden Anforderungen, um weiterhin auf diese Organisation zuzugreifen", "youCanNowAccessOrganization": "Sie können nun auf diese Organisation zugreifen", "reauthenticationRequired": "Sitzungslänge", "reauthenticationDescription": "Diese Organisation erfordert, dass Sie sich alle {maxDays} Tage anmelden.", "reauthenticationDescriptionHours": "Diese Organisation erfordert, dass Sie sich alle {maxHours} Stunden einloggen.", "reauthenticateNow": "Erneut anmelden", "adminEnabled2FaOnYourAccount": "Ihr Administrator hat die Zwei-Faktor-Authentifizierung für {email} aktiviert. Bitte schließen Sie den Einrichtungsprozess ab, um fortzufahren.", "securityKeyAdd": "Sicherheitsschlüssel hinzufügen", "securityKeyRegisterTitle": "Neuen Sicherheitsschlüssel registrieren", "securityKeyRegisterDescription": "Verbinden Sie Ihren Sicherheitsschlüssel und geben Sie einen Namen ein, um ihn zu identifizieren", "securityKeyTwoFactorRequired": "Zwei-Faktor-Authentifizierung erforderlich", "securityKeyTwoFactorDescription": "Bitte geben Sie Ihren Zwei-Faktor-Authentifizierungscode ein, um den Sicherheitsschlüssel zu registrieren", "securityKeyTwoFactorRemoveDescription": "Bitte geben Sie Ihren Zwei-Faktor-Authentifizierungscode ein, um den Sicherheitsschlüssel zu entfernen", "securityKeyTwoFactorCode": "Zwei-Faktor-Code", "securityKeyRemoveTitle": "Sicherheitsschlüssel entfernen", "securityKeyRemoveDescription": "Geben Sie Ihr Passwort ein, um den Sicherheitsschlüssel \"{name}\" zu entfernen", "securityKeyNoKeysRegistered": "Keine Sicherheitsschlüssel registriert", "securityKeyNoKeysDescription": "Fügen Sie einen Sicherheitsschlüssel hinzu, um die Sicherheit Ihres Kontos zu erhöhen", "createDomainRequired": "Domain ist erforderlich", "createDomainAddDnsRecords": "DNS-Einträge hinzufügen", "createDomainAddDnsRecordsDescription": "Fügen Sie die folgenden DNS-Einträge zu Ihrem Domain-Provider hinzu, um die Einrichtung abzuschließen.", "createDomainNsRecords": "NS-Einträge", "createDomainRecord": "Eintrag", "createDomainType": "Typ:", "createDomainName": "Name:", "createDomainValue": "Wert:", "createDomainCnameRecords": "CNAME-Einträge", "createDomainARecords": "A-Aufzeichnungen", "createDomainRecordNumber": "Eintrag {number}", "createDomainTxtRecords": "TXT-Einträge", "createDomainSaveTheseRecords": "Diese Einträge speichern", "createDomainSaveTheseRecordsDescription": "Achten Sie darauf, diese DNS-Einträge zu speichern, da Sie sie nicht erneut sehen werden.", "createDomainDnsPropagation": "DNS-Verbreitung", "createDomainDnsPropagationDescription": "Es kann einige Zeit dauern, bis DNS-Änderungen im Internet verbreitet werden. Dies kann je nach Ihrem DNS-Provider und den TTL-Einstellungen von einigen Minuten bis zu 48 Stunden dauern.", "resourcePortRequired": "Portnummer ist für nicht-HTTP-Ressourcen erforderlich", "resourcePortNotAllowed": "Portnummer sollte für HTTP-Ressourcen nicht gesetzt werden", "billingPricingCalculatorLink": "Preisrechner", "billingYourPlan": "Ihr Plan", "billingViewOrModifyPlan": "Zeige oder ändere dein aktuelles Paket", "billingViewPlanDetails": "Plan Details anzeigen", "billingUsageAndLimits": "Nutzung und Einschränkungen", "billingViewUsageAndLimits": "Schau dir die Grenzen und die aktuelle Nutzung deines Plans an", "billingCurrentUsage": "Aktuelle Nutzung", "billingMaximumLimits": "Maximale Grenzen", "billingRemoteNodes": "Entfernte Knoten", "billingUnlimited": "Unbegrenzt", "billingPaidLicenseKeys": "Bezahlte Lizenzschlüssel", "billingManageLicenseSubscription": "Verwalten Sie Ihr Abonnement für kostenpflichtige selbstgehostete Lizenzschlüssel", "billingCurrentKeys": "Aktuelle Tasten", "billingModifyCurrentPlan": "Aktuelles Paket ändern", "billingConfirmUpgrade": "Upgrade bestätigen", "billingConfirmDowngrade": "Downgrade bestätigen", "billingConfirmUpgradeDescription": "Sie sind dabei, Ihr Paket zu aktualisieren. Schauen Sie sich die neuen Limits und Preise unten an.", "billingConfirmDowngradeDescription": "Sie sind dabei, Ihren Plan herunterzustufen. Überprüfen Sie die neuen Limits und Preise unten.", "billingPlanIncludes": "Plan beinhaltet", "billingProcessing": "Verarbeitung...", "billingConfirmUpgradeButton": "Upgrade bestätigen", "billingConfirmDowngradeButton": "Downgrade bestätigen", "billingLimitViolationWarning": "Nutzung überschreitet neue Plan-Grenzen", "billingLimitViolationDescription": "Ihre aktuelle Nutzung überschreitet die Grenzen dieses Plans. Nach dem Downgrade werden alle Aktionen deaktiviert, bis Sie die Nutzung innerhalb der neuen Grenzen reduzieren. Bitte überprüfen Sie die Funktionen unten, die derzeit über den Grenzen liegen. Grenzwerte verletzen:", "billingFeatureLossWarning": "Verfügbarkeitshinweis", "billingFeatureLossDescription": "Durch Herabstufung werden Funktionen, die im neuen Paket nicht verfügbar sind, automatisch deaktiviert. Einige Einstellungen und Konfigurationen können verloren gehen. Bitte überprüfen Sie die Preismatrix um zu verstehen, welche Funktionen nicht mehr verfügbar sein werden.", "billingUsageExceedsLimit": "Aktuelle Nutzung ({current}) überschreitet das Limit ({limit})", "billingPastDueTitle": "Zahlung vergangene Fälligkeit", "billingPastDueDescription": "Ihre Zahlung ist abgelaufen. Bitte aktualisieren Sie Ihre Zahlungsmethode, um die aktuellen Funktionen Ihres Pakets weiter zu nutzen. Wenn nicht geklärt, wird Ihr Abonnement abgebrochen und Sie werden auf die kostenlose Stufe zurückgekehrt.", "billingUnpaidTitle": "Unbezahltes Abonnement", "billingUnpaidDescription": "Dein Abonnement ist unbezahlt und du wurdest auf die kostenlose Stufe zurückgekehrt. Bitte aktualisiere deine Zahlungsmethode, um dein Abonnement wiederherzustellen.", "billingIncompleteTitle": "Zahlung unvollständig", "billingIncompleteDescription": "Ihre Zahlung ist unvollständig. Bitte schließen Sie den Zahlungsvorgang ab, um Ihr Abonnement zu aktivieren.", "billingIncompleteExpiredTitle": "Zahlung abgelaufen", "billingIncompleteExpiredDescription": "Deine Zahlung wurde nie abgeschlossen und ist abgelaufen. Du wurdest zur kostenlosen Stufe zurückgekehrt. Bitte melde dich erneut an, um den Zugriff auf kostenpflichtige Funktionen wiederherzustellen.", "billingManageSubscription": "Verwalten Sie Ihr Abonnement", "billingResolvePaymentIssue": "Bitte beheben Sie Ihr Zahlungsproblem vor dem Upgrade oder Herabstufen", "signUpTerms": { "IAgreeToThe": "Ich stimme den", "termsOfService": "Nutzungsbedingungen zu", "and": "und", "privacyPolicy": "datenschutzrichtlinie." }, "signUpMarketing": { "keepMeInTheLoop": "Halten Sie mich auf dem Laufenden mit Neuigkeiten, Updates und neuen Funktionen per E-Mail." }, "siteRequired": "Standort ist erforderlich.", "olmTunnel": "Olm-Tunnel", "olmTunnelDescription": "Nutzen Sie Olm für die Client-Verbindung", "errorCreatingClient": "Fehler beim Erstellen des Clients", "clientDefaultsNotFound": "Standardeinstellungen des Clients nicht gefunden", "createClient": "Client erstellen", "createClientDescription": "Neuen Client erstellen um auf private Ressourcen zuzugreifen", "seeAllClients": "Alle Clients anzeigen", "clientInformation": "Client-Informationen", "clientNamePlaceholder": "Client-Name", "address": "Adresse", "subnetPlaceholder": "Subnetz", "addressDescription": "Die interne Adresse des Clients. Muss in das Subnetz der Organisation fallen.", "selectSites": "Standorte auswählen", "sitesDescription": "Der Client wird zu den ausgewählten Standorten eine Verbindung haben.", "clientInstallOlm": "Olm installieren", "clientInstallOlmDescription": "Olm auf Ihrem System zum Laufen bringen", "clientOlmCredentials": "Zugangsdaten", "clientOlmCredentialsDescription": "So wird sich der Client mit dem Server authentifizieren", "olmEndpoint": "Endpunkt", "olmId": "ID", "olmSecretKey": "Geheimnis", "clientCredentialsSave": "Anmeldedaten speichern", "clientCredentialsSaveDescription": "Sie können dies nur einmal sehen. Kopieren Sie es an einen sicheren Ort.", "generalSettingsDescription": "Konfigurieren Sie die allgemeinen Einstellungen für diesen Client", "clientUpdated": "Client aktualisiert", "clientUpdatedDescription": "Der Client wurde aktualisiert.", "clientUpdateFailed": "Fehler beim Aktualisieren des Clients", "clientUpdateError": "Beim Aktualisieren des Clients ist ein Fehler aufgetreten.", "sitesFetchFailed": "Fehler beim Abrufen von Standorten", "sitesFetchError": "Beim Abrufen von Standorten ist ein Fehler aufgetreten.", "olmErrorFetchReleases": "Beim Abrufen von Olm-Veröffentlichungen ist ein Fehler aufgetreten.", "olmErrorFetchLatest": "Beim Abrufen der neuesten Olm-Veröffentlichung ist ein Fehler aufgetreten.", "enterCidrRange": "Geben Sie den CIDR-Bereich ein", "resourceEnableProxy": "Öffentlichen Proxy aktivieren", "resourceEnableProxyDescription": "Ermöglichen Sie öffentliches Proxieren zu dieser Ressource. Dies ermöglicht den Zugriff auf die Ressource von außerhalb des Netzwerks durch die Cloud über einen offenen Port. Erfordert Traefik-Config.", "externalProxyEnabled": "Externer Proxy aktiviert", "addNewTarget": "Neues Ziel hinzufügen", "targetsList": "Ziel-Liste", "advancedMode": "Erweiterter Modus", "advancedSettings": "Erweiterte Einstellungen", "targetErrorDuplicateTargetFound": "Doppeltes Ziel gefunden", "healthCheckHealthy": "Gesund", "healthCheckUnhealthy": "Ungesund", "healthCheckUnknown": "Unbekannt", "healthCheck": "Gesundheits-Check", "configureHealthCheck": "Gesundheits-Check konfigurieren", "configureHealthCheckDescription": "Richten Sie die Gesundheitsüberwachung für {target} ein", "enableHealthChecks": "Gesundheits-Checks aktivieren", "enableHealthChecksDescription": "Überwachen Sie die Gesundheit dieses Ziels. Bei Bedarf können Sie einen anderen Endpunkt als das Ziel überwachen.", "healthScheme": "Methode", "healthSelectScheme": "Methode auswählen", "healthCheckPortInvalid": "Der Gesundheitskontroll-Port muss zwischen 1 und 65535 liegen", "healthCheckPath": "Pfad", "healthHostname": "IP / Host", "healthPort": "Port", "healthCheckPathDescription": "Der Pfad zum Überprüfen des Gesundheitszustands.", "healthyIntervalSeconds": "Gesundes Intervall (Sek.)", "unhealthyIntervalSeconds": "Ungesundes Intervall (Sek)", "IntervalSeconds": "Gesunder Intervall", "timeoutSeconds": "Timeout (Sek.)", "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": "Kommagetrennte Liste von Befehlen, die der Benutzer mit sudo ausführen darf.", "sshCreateHomeDir": "Home-Verzeichnis erstellen", "sshUnixGroups": "Unix-Gruppen", "sshUnixGroupsDescription": "Durch Komma getrennte Unix-Gruppen, um den Benutzer auf dem Zielhost hinzuzufügen.", "retryAttempts": "Wiederholungsversuche", "expectedResponseCodes": "Erwartete Antwortcodes", "expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.", "customHeaders": "Eigene Kopfzeilen", "customHeadersDescription": "Header neue Zeile getrennt: Header-Name: Wert", "headersValidationError": "Header müssen im Format Header-Name: Wert sein.", "saveHealthCheck": "Gesundheits-Check speichern", "healthCheckSaved": "Gesundheits-Check gespeichert", "healthCheckSavedDescription": "Die Konfiguration des Gesundheits-Checks wurde erfolgreich gespeichert", "healthCheckError": "Fehler beim Gesundheits-Check", "healthCheckErrorDescription": "Beim Speichern der Gesundheits-Check-Konfiguration ist ein Fehler aufgetreten", "healthCheckPathRequired": "Gesundheits-Check-Pfad ist erforderlich", "healthCheckMethodRequired": "HTTP-Methode ist erforderlich", "healthCheckIntervalMin": "Prüfintervall muss mindestens 5 Sekunden betragen", "healthCheckTimeoutMin": "Zeitüberschreitung muss mindestens 1 Sekunde betragen", "healthCheckRetryMin": "Wiederholungsversuche müssen mindestens 1 betragen", "httpMethod": "HTTP-Methode", "selectHttpMethod": "HTTP-Methode auswählen", "domainPickerSubdomainLabel": "Subdomain", "domainPickerBaseDomainLabel": "Basisdomain", "domainPickerSearchDomains": "Domains suchen...", "domainPickerNoDomainsFound": "Keine Domains gefunden", "domainPickerLoadingDomains": "Domains werden geladen...", "domainPickerSelectBaseDomain": "Basisdomain auswählen...", "domainPickerNotAvailableForCname": "Für CNAME-Domains nicht verfügbar", "domainPickerEnterSubdomainOrLeaveBlank": "Geben Sie eine Subdomain ein oder lassen Sie das Feld leer, um die Basisdomain zu verwenden.", "domainPickerEnterSubdomainToSearch": "Geben Sie eine Subdomain ein, um verfügbare freie Domains zu suchen und auszuwählen.", "domainPickerFreeDomains": "Freie Domains", "domainPickerSearchForAvailableDomains": "Verfügbare Domains suchen", "domainPickerNotWorkSelfHosted": "Hinweis: Kostenlose bereitgestellte Domains sind derzeit nicht für selbstgehostete Instanzen verfügbar.", "resourceDomain": "Domäne", "resourceEditDomain": "Domain bearbeiten", "siteName": "Standortname", "proxyPort": "Port", "resourcesTableProxyResources": "Öffentlich", "resourcesTableClientResources": "Privat", "resourcesTableNoProxyResourcesFound": "Keine Proxy-Ressourcen gefunden.", "resourcesTableNoInternalResourcesFound": "Keine internen Ressourcen gefunden.", "resourcesTableDestination": "Ziel", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Alias-Adresse", "resourcesTableAliasAddressInfo": "Diese Adresse ist Teil des Utility-Subnetzes der Organisation. Sie wird verwendet, um Alias-Einträge mit interner DNS-Auflösung aufzulösen.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "und sind nur intern zugänglich, wenn mit einem Client verbunden.", "resourcesTableNoTargets": "Keine Ziele", "resourcesTableHealthy": "Gesund", "resourcesTableDegraded": "Degradiert", "resourcesTableOffline": "Offline", "resourcesTableUnknown": "Unbekannt", "resourcesTableNotMonitored": "Nicht überwacht", "editInternalResourceDialogEditClientResource": "Private Ressource bearbeiten", "editInternalResourceDialogUpdateResourceProperties": "Ressourcen-Konfiguration und Zugriffssteuerung für {resourceName} aktualisieren", "editInternalResourceDialogResourceProperties": "Ressourceneigenschaften", "editInternalResourceDialogName": "Name", "editInternalResourceDialogProtocol": "Protokoll", "editInternalResourceDialogSitePort": "Standort Port", "editInternalResourceDialogTargetConfiguration": "Zielkonfiguration", "editInternalResourceDialogCancel": "Abbrechen", "editInternalResourceDialogSaveResource": "Ressource speichern", "editInternalResourceDialogSuccess": "Erfolg", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Interne Ressource erfolgreich aktualisiert", "editInternalResourceDialogError": "Fehler", "editInternalResourceDialogFailedToUpdateInternalResource": "Interne Ressource konnte nicht aktualisiert werden", "editInternalResourceDialogNameRequired": "Name ist erforderlich", "editInternalResourceDialogNameMaxLength": "Der Name darf nicht länger als 255 Zeichen sein", "editInternalResourceDialogProxyPortMin": "Proxy-Port muss mindestens 1 sein", "editInternalResourceDialogProxyPortMax": "Proxy-Port muss kleiner als 65536 sein", "editInternalResourceDialogInvalidIPAddressFormat": "Ungültiges IP-Adressformat", "editInternalResourceDialogDestinationPortMin": "Ziel-Port muss mindestens 1 sein", "editInternalResourceDialogDestinationPortMax": "Ziel-Port muss kleiner als 65536 sein", "editInternalResourceDialogPortModeRequired": "Protokolle, Proxyport und Zielport werden für den Port-Modus benötigt", "editInternalResourceDialogMode": "Modus", "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Ziel", "editInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.", "editInternalResourceDialogDestinationIPDescription": "Die IP-Adresse oder Hostname Adresse der Ressource im Netzwerk der Website.", "editInternalResourceDialogDestinationCidrDescription": "Der CIDR-Bereich der Ressource im Netzwerk der Website.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.", "createInternalResourceDialogNoSitesAvailable": "Kein Standort verfügbar", "createInternalResourceDialogNoSitesAvailableDescription": "Sie müssen mindestens ein Newt-Standort mit einem konfigurierten Subnetz haben, um interne Ressourcen zu erstellen.", "createInternalResourceDialogClose": "Schließen", "createInternalResourceDialogCreateClientResource": "Private Ressource erstellen", "createInternalResourceDialogCreateClientResourceDescription": "Erstelle eine neue Ressource, die nur für Clients zugänglich ist, die mit der Organisation verbunden sind", "createInternalResourceDialogResourceProperties": "Ressourceneigenschaften", "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Standort", "selectSite": "Standort auswählen...", "noSitesFound": "Keine Standorte gefunden.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Standort Port", "createInternalResourceDialogSitePortDescription": "Verwenden Sie diesen Port, um bei Verbindung mit einem Client auf die Ressource an der Site zuzugreifen.", "createInternalResourceDialogTargetConfiguration": "Zielkonfiguration", "createInternalResourceDialogDestinationIPDescription": "Die IP-Adresse oder Hostname Adresse der Ressource im Netzwerk der Website.", "createInternalResourceDialogDestinationPortDescription": "Der Port auf der Ziel-IP, unter dem die Ressource zugänglich ist.", "createInternalResourceDialogCancel": "Abbrechen", "createInternalResourceDialogCreateResource": "Ressource erstellen", "createInternalResourceDialogSuccess": "Erfolg", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Interne Ressource erfolgreich erstellt", "createInternalResourceDialogError": "Fehler", "createInternalResourceDialogFailedToCreateInternalResource": "Interne Ressource konnte nicht erstellt werden", "createInternalResourceDialogNameRequired": "Name ist erforderlich", "createInternalResourceDialogNameMaxLength": "Der Name darf nicht länger als 255 Zeichen sein", "createInternalResourceDialogPleaseSelectSite": "Bitte wählen Sie einen Standort aus", "createInternalResourceDialogProxyPortMin": "Proxy-Port muss mindestens 1 sein", "createInternalResourceDialogProxyPortMax": "Proxy-Port muss kleiner als 65536 sein", "createInternalResourceDialogInvalidIPAddressFormat": "Ungültiges IP-Adressformat", "createInternalResourceDialogDestinationPortMin": "Ziel-Port muss mindestens 1 sein", "createInternalResourceDialogDestinationPortMax": "Ziel-Port muss kleiner als 65536 sein", "createInternalResourceDialogPortModeRequired": "Protokolle, Proxyport und Zielport werden für den Port-Modus benötigt", "createInternalResourceDialogMode": "Modus", "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Ziel", "createInternalResourceDialogDestinationHostDescription": "Die IP-Adresse oder der Hostname der Ressource im Netzwerk der Website.", "createInternalResourceDialogDestinationCidrDescription": "Der CIDR-Bereich der Ressource im Netzwerk der Website.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.", "siteConfiguration": "Konfiguration", "siteAcceptClientConnections": "Clientverbindungen akzeptieren", "siteAcceptClientConnectionsDescription": "Erlaube Benutzer-Geräten und Clients Zugriff auf Ressourcen auf diesem Standort. Dies kann später geändert werden.", "siteAddress": "Standort-Adresse (Erweitert)", "siteAddressDescription": "Die interne Adresse des Standorts. Sie muss im Subnetz der Organisation liegen.", "siteNameDescription": "Der Anzeigename des Standorts, kann später geändert werden", "autoLoginExternalIdp": "Automatische Anmeldung mit externem IDP", "autoLoginExternalIdpDescription": "Den Nutzer zur Authentifizierung sofort an den externen Identifikationsanbieter weiterleiten.", "selectIdp": "IDP auswählen", "selectIdpPlaceholder": "Wählen Sie einen IDP...", "selectIdpRequired": "Bitte wählen Sie einen IDP aus, wenn automatische Anmeldung aktiviert ist.", "autoLoginTitle": "Weiterleitung", "autoLoginDescription": "Sie werden zum externen Identitätsanbieter zur Authentifizierung weitergeleitet.", "autoLoginProcessing": "Authentifizierung vorbereiten...", "autoLoginRedirecting": "Weiterleitung zur Anmeldung...", "autoLoginError": "Fehler bei der automatischen Anmeldung", "autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.", "autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.", "remoteExitNodeManageRemoteExitNodes": "Entfernte Knoten", "remoteExitNodeDescription": "Hosten Sie selbst Ihr eigenes Relay und ihre Server Nodes", "remoteExitNodes": "Knoten", "searchRemoteExitNodes": "Knoten suchen...", "remoteExitNodeAdd": "Knoten hinzufügen", "remoteExitNodeErrorDelete": "Fehler beim Löschen des Knotens", "remoteExitNodeQuestionRemove": "Sind Sie sicher, dass Sie den Knoten aus der Organisation entfernen möchten?", "remoteExitNodeMessageRemove": "Einmal entfernt, wird der Knoten nicht mehr zugänglich sein.", "remoteExitNodeConfirmDelete": "Löschknoten bestätigen", "remoteExitNodeDelete": "Knoten löschen", "sidebarRemoteExitNodes": "Entfernte Knoten", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Geheimnis", "remoteExitNodeCreate": { "title": "Erstelle Remote Node", "description": "Erstelle einen neues selbst gehostetes Relay und ihre Proxyserver Nodes", "viewAllButton": "Alle Knoten anzeigen", "strategy": { "title": "Erstellungsstrategie", "description": "Wählen Sie, wie Sie den entfernten Node erstellen möchten", "adopt": { "title": "Node übernehmen", "description": "Wählen Sie dies, wenn Sie bereits die Anmeldedaten für den Knoten haben." }, "generate": { "title": "Schlüssel generieren", "description": "Wählen Sie dies, wenn Sie neue Schlüssel für den Node generieren möchten." } }, "adopt": { "title": "Vorhandenen Node übernehmen", "description": "Geben Sie die Zugangsdaten des vorhandenen Knotens ein, den Sie übernehmen möchten", "nodeIdLabel": "Knoten-ID", "nodeIdDescription": "Die ID des vorhandenen Knotens, den Sie übernehmen möchten", "secretLabel": "Geheimnis", "secretDescription": "Der geheime Schlüssel des vorhandenen Knotens", "submitButton": "Node übernehmen" }, "generate": { "title": "Generierte Anmeldedaten", "description": "Diese generierten Anmeldeinformationen verwenden, um den Knoten zu konfigurieren", "nodeIdTitle": "Knoten-ID", "secretTitle": "Geheimnis", "saveCredentialsTitle": "Anmeldedaten zur Konfiguration hinzufügen", "saveCredentialsDescription": "Fügen Sie diese Anmeldedaten zu Ihrer selbst-gehosteten Pangolin Node-Konfigurationsdatei hinzu, um die Verbindung abzuschließen.", "submitButton": "Knoten erstellen" }, "validation": { "adoptRequired": "Knoten-ID und Geheimnis sind erforderlich, wenn ein existierender Knoten angenommen wird" }, "errors": { "loadDefaultsFailed": "Fehler beim Laden der Standardeinstellungen", "defaultsNotLoaded": "Standardeinstellungen nicht geladen", "createFailed": "Knoten konnte nicht erstellt werden" }, "success": { "created": "Knoten erfolgreich erstellt" } }, "remoteExitNodeSelection": "Knotenauswahl", "remoteExitNodeSelectionDescription": "Wählen Sie einen Knoten aus, durch den Traffic für diese lokale Seite geleitet werden soll", "remoteExitNodeRequired": "Ein Knoten muss für lokale Seiten ausgewählt sein", "noRemoteExitNodesAvailable": "Keine Knoten verfügbar", "noRemoteExitNodesAvailableDescription": "Für diese Organisation sind keine Knoten verfügbar. Erstellen Sie zuerst einen Knoten, um lokale Standorte zu verwenden.", "exitNode": "Exit-Node", "country": "Land", "rulesMatchCountry": "Derzeit basierend auf der Quell-IP", "managedSelfHosted": { "title": "Verwaltetes Selbsthosted", "description": "Zuverlässiger und wartungsarmer Pangolin Server mit zusätzlichen Glocken und Pfeifen", "introTitle": "Verwalteter selbstgehosteter Pangolin", "introDescription": "ist eine Deployment-Option, die für Personen konzipiert wurde, die Einfachheit und zusätzliche Zuverlässigkeit wünschen, während sie ihre Daten privat und selbstgehostet halten.", "introDetail": "Mit dieser Option haben Sie immer noch Ihren eigenen Pangolin-Knoten – Ihre Tunnel, SSL-Terminierung und Traffic bleiben auf Ihrem Server. Der Unterschied besteht darin, dass Verwaltung und Überwachung über unser Cloud-Dashboard abgewickelt werden, das eine Reihe von Vorteilen freischaltet:", "benefitSimplerOperations": { "title": "Einfachere Operationen", "description": "Sie brauchen keinen eigenen Mail-Server auszuführen oder komplexe Warnungen einzurichten. Sie erhalten Gesundheitschecks und Ausfallwarnungen aus dem Box." }, "benefitAutomaticUpdates": { "title": "Automatische Updates", "description": "Das Cloud-Dashboard entwickelt sich schnell, so dass Sie neue Funktionen und Fehlerbehebungen erhalten, ohne jedes Mal neue Container manuell ziehen zu müssen." }, "benefitLessMaintenance": { "title": "Weniger Wartung", "description": "Keine Datenbankmigrationen, Sicherungen oder zusätzliche Infrastruktur zum Verwalten. Wir kümmern uns um das in der Cloud." }, "benefitCloudFailover": { "title": "Cloud-Ausfall", "description": "Wenn Ihr Knoten runtergeht, können Ihre Tunnel vorübergehend an unsere Cloud-Punkte scheitern, bis Sie ihn wieder online bringen." }, "benefitHighAvailability": { "title": "Hohe Verfügbarkeit (PoPs)", "description": "Sie können auch mehrere Knoten an Ihr Konto anhängen, um Redundanz und bessere Leistung zu erzielen." }, "benefitFutureEnhancements": { "title": "Zukünftige Verbesserungen", "description": "Wir planen weitere Analyse-, Alarm- und Management-Tools hinzuzufügen, um Ihren Einsatz noch robuster zu machen." }, "docsAlert": { "text": "Erfahren Sie mehr über die Managed Self-Hosted Option in unserer", "documentation": "dokumentation" }, "convertButton": "Diesen Knoten in Managed Self-Hosted umwandeln" }, "internationaldomaindetected": "Internationale Domain erkannt", "willbestoredas": "Wird gespeichert als:", "roleMappingDescription": "Legen Sie fest, wie den Benutzern Rollen zugewiesen werden, wenn sie sich anmelden, wenn Auto Provision aktiviert ist.", "selectRole": "Wählen Sie eine Rolle", "roleMappingExpression": "Ausdruck", "selectRolePlaceholder": "Rolle auswählen", "selectRoleDescription": "Wählen Sie eine Rolle aus, die allen Benutzern von diesem Identitätsprovider zugewiesen werden soll", "roleMappingExpressionDescription": "Geben Sie einen JMESPath-Ausdruck ein, um Rolleninformationen aus dem ID-Token zu extrahieren", "idpTenantIdRequired": "Mandant ID ist erforderlich", "invalidValue": "Ungültiger Wert", "idpTypeLabel": "Identitätsanbietertyp", "roleMappingExpressionPlaceholder": "z. B. enthalten(Gruppen, 'admin') && 'Admin' || 'Mitglied'", "idpGoogleConfiguration": "Google-Konfiguration", "idpGoogleConfigurationDescription": "Google OAuth2 Zugangsdaten konfigurieren", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Google OAuth2 Client Geheimnis", "idpAzureConfiguration": "Azure Entra ID Konfiguration", "idpAzureConfigurationDescription": "Azure Entra ID OAuth2 Zugangsdaten konfigurieren", "idpTenantId": "Mandanten-ID", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "Azure Tenant ID (gefunden in Azure Active Directory Übersicht)", "idpAzureClientIdDescription": "Azure App Registration Client ID", "idpAzureClientSecretDescription": "Azure App Registration Client Geheimnis", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Google-Konfiguration", "idpAzureConfigurationTitle": "Azure Entra ID Konfiguration", "idpTenantIdLabel": "Mandanten-ID", "idpAzureClientIdDescription2": "Azure App Registration Client ID", "idpAzureClientSecretDescription2": "Azure App Registration Client Geheimnis", "idpGoogleDescription": "Google OAuth2/OIDC Provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnetz", "subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.", "customDomain": "Eigene Domain", "authPage": "Authentifizierungs-Seiten", "authPageDescription": "Legen Sie eine eigene Domain für die Authentifizierungsseiten der Organisation fest", "authPageDomain": "Domain der Auth Seite", "authPageBranding": "Eigenes Branding", "authPageBrandingDescription": "Konfigurieren Sie das Branding, das auf Authentifizierungsseiten für diese Organisation erscheint", "authPageBrandingUpdated": "Branding der Authentifizierungsseiten erfolgreich aktualisiert", "authPageBrandingRemoved": "Branding der Authentifizierungsseiten erfolgreich entfernt", "authPageBrandingRemoveTitle": "Authentifizierungsseiten Branding entfernen", "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)", "brandingOrgTitle": "Titel für die Authentifizierungsseite der Organisation", "brandingOrgDescription": "{orgName} wird durch den Namen der Organisation ersetzt", "brandingOrgSubtitle": "Untertitel für die Authentifizierungsseite der Organisation", "brandingResourceTitle": "Titel für die Ressourcen-Authentifizierungsseite", "brandingResourceSubtitle": "Untertitel für Ressourcen-Authentifizierungsseite", "brandingResourceDescription": "{resourceName} wird durch den Namen der Organisation ersetzt", "saveAuthPageDomain": "Domain speichern", "saveAuthPageBranding": "Branding speichern", "removeAuthPageBranding": "Branding entfernen", "noDomainSet": "Keine Domain gesetzt", "changeDomain": "Domain ändern", "selectDomain": "Domain auswählen", "restartCertificate": "Zertifikat neu starten", "editAuthPageDomain": "Auth Page Domain bearbeiten", "setAuthPageDomain": "Domain der Auth Seite festlegen", "failedToFetchCertificate": "Zertifikat konnte nicht abgerufen werden", "failedToRestartCertificate": "Zertifikat konnte nicht neu gestartet werden", "addDomainToEnableCustomAuthPages": "Benutzer können über diese Domain auf die Login-Seite der Organisation zugreifen und die Ressourcen-Authentifizierung durchführen.", "selectDomainForOrgAuthPage": "Wählen Sie eine Domain für die Authentifizierungsseite der Organisation", "domainPickerProvidedDomain": "Angegebene Domain", "domainPickerFreeProvidedDomain": "Kostenlose Domain", "domainPickerVerified": "Verifiziert", "domainPickerUnverified": "Nicht verifiziert", "domainPickerInvalidSubdomainStructure": "Diese Subdomain enthält ungültige Zeichen oder Struktur. Sie wird beim Speichern automatisch bereinigt.", "domainPickerError": "Fehler", "domainPickerErrorLoadDomains": "Fehler beim Laden der Organisations-Domains", "domainPickerErrorCheckAvailability": "Fehler beim Prüfen der Domain-Verfügbarkeit", "domainPickerInvalidSubdomain": "Ungültige Subdomain", "domainPickerInvalidSubdomainRemoved": "Die Eingabe \"{sub}\" wurde entfernt, weil sie nicht gültig ist.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" konnte nicht für {domain} gültig gemacht werden.", "domainPickerSubdomainSanitized": "Subdomain bereinigt", "domainPickerSubdomainCorrected": "\"{sub}\" wurde korrigiert zu \"{sanitized}\"", "orgAuthSignInTitle": "Organisations-Anmeldung", "orgAuthChooseIdpDescription": "Wähle deinen Identitätsanbieter um fortzufahren", "orgAuthNoIdpConfigured": "Diese Organisation hat keine Identitätsanbieter konfiguriert. Sie können sich stattdessen mit Ihrer Pangolin-Identität anmelden.", "orgAuthSignInWithPangolin": "Mit Pangolin anmelden", "orgAuthSignInToOrg": "Bei einer Organisation anmelden", "orgAuthSelectOrgTitle": "Organisations-Anmeldung", "orgAuthSelectOrgDescription": "Geben Sie Ihre Organisations-ID ein, um fortzufahren", "orgAuthOrgIdPlaceholder": "Ihre Organisation", "orgAuthOrgIdHelp": "Geben Sie die eindeutige Kennung Ihrer Organisation ein", "orgAuthSelectOrgHelp": "Nachdem Sie Ihre Organisations-ID eingegeben haben, werden Sie auf die Anmeldeseite Ihrer Organisation gebracht, auf der Sie SSO oder die Zugangsdaten Ihrer Organisation verwenden können.", "orgAuthRememberOrgId": "Diese Organisations-ID merken", "orgAuthBackToSignIn": "Zurück zum Standard Login", "orgAuthNoAccount": "Sie haben noch kein Konto?", "subscriptionRequiredToUse": "Um diese Funktion nutzen zu können, ist ein Abonnement erforderlich.", "mustUpgradeToUse": "Sie müssen Ihr Abonnement aktualisieren, um diese Funktion nutzen zu können.", "subscriptionRequiredTierToUse": "Diese Funktion erfordert {tier} oder höher.", "upgradeToTierToUse": "Upgrade auf {tier} oder höher, um diese Funktion zu nutzen.", "subscriptionTierTier1": "Zuhause", "subscriptionTierTier2": "Team", "subscriptionTierTier3": "Geschäftlich", "subscriptionTierEnterprise": "Firma", "idpDisabled": "Identitätsanbieter sind deaktiviert.", "orgAuthPageDisabled": "Organisations-Authentifizierungsseite ist deaktiviert.", "domainRestartedDescription": "Domain-Verifizierung erfolgreich neu gestartet", "resourceAddEntrypointsEditFile": "Datei bearbeiten: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Datei bearbeiten: docker-compose.yml", "emailVerificationRequired": "E-Mail-Verifizierung ist erforderlich. Bitte melden Sie sich erneut über {dashboardUrl}/auth/login an. Kommen Sie dann wieder hierher.", "twoFactorSetupRequired": "Die Zwei-Faktor-Authentifizierung ist erforderlich. Bitte melden Sie sich erneut über {dashboardUrl}/auth/login an. Dann kommen Sie hierher zurück.", "additionalSecurityRequired": "Zusätzliche Sicherheit erforderlich", "organizationRequiresAdditionalSteps": "Diese Organisation erfordert zusätzliche Sicherheitsschritte, bevor Sie auf Ressourcen zugreifen können.", "completeTheseSteps": "Schließe diese Schritte ab", "enableTwoFactorAuthentication": "Zwei-Faktor-Authentifizierung aktivieren", "completeSecuritySteps": "Schließe Sicherheitsschritte ab", "securitySettings": "Sicherheitseinstellungen", "dangerSection": "Gefahrenzone", "dangerSectionDescription": "Alle mit dieser Organisation verbundenen Daten dauerhaft löschen", "securitySettingsDescription": "Sicherheitsrichtlinien für die Organisation konfigurieren", "requireTwoFactorForAllUsers": "Zwei-Faktor-Authentifizierung für alle Benutzer erforderlich", "requireTwoFactorDescription": "Wenn aktiviert, müssen alle internen Benutzer in dieser Organisation die Zwei-Faktor-Authentifizierung aktiviert haben, um auf die Organisation zuzugreifen.", "requireTwoFactorDisabledDescription": "Diese Funktion erfordert eine gültige Lizenz (Unternehmen) oder ein aktives Abonnement (SaaS)", "requireTwoFactorCannotEnableDescription": "Sie müssen die Zwei-Faktor-Authentifizierung für Ihr Konto aktivieren, bevor Sie es für alle Benutzer erzwingen können", "maxSessionLength": "Maximale Sitzungslänge", "maxSessionLengthDescription": "Legen Sie die maximale Dauer für Benutzersitzungen fest. Nach dieser Zeit müssen Benutzer erneut authentifizieren.", "maxSessionLengthDisabledDescription": "Diese Funktion erfordert eine gültige Lizenz (Unternehmen) oder ein aktives Abonnement (SaaS)", "selectSessionLength": "Sitzungslänge auswählen", "unenforced": "Unerzwungen", "1Hour": "1 Stunde", "3Hours": "3 Stunden", "6Hours": "6 Stunden", "12Hours": "12 Stunden", "1DaySession": "1 Tag", "3Days": "3 Tage", "7Days": "7 Tage", "14Days": "14 Tage", "30DaysSession": "30 Tage", "90DaysSession": "90 Tage", "180DaysSession": "180 Tage", "passwordExpiryDays": "Passwortablauf", "editPasswordExpiryDescription": "Legen Sie die Anzahl der Tage fest, bevor Benutzer ihr Passwort ändern müssen.", "selectPasswordExpiry": "Ablauf des Passworts auswählen", "30Days": "30 Tage", "1Day": "1 Tag", "60Days": "60 Tage", "90Days": "90 Tage", "180Days": "180 Tage", "1Year": "1 Jahr", "subscriptionBadge": "Abonnement erforderlich", "securityPolicyChangeWarning": "Sicherheitsrichtlinienänderungs-Warnung", "securityPolicyChangeDescription": "Sie sind dabei, die Sicherheitseinstellungen zu ändern. Nach dem Speichern müssen Sie sich erneut authentifizieren, um diesen Richtlinien-Aktualisierungen nachzukommen. Alle Benutzer, die nicht konform sind, müssen sich ebenfalls neu authentifizieren.", "securityPolicyChangeConfirmMessage": "Ich bestätige", "securityPolicyChangeWarningText": "Dies betrifft alle Benutzer in der Organisation", "authPageErrorUpdateMessage": "Beim Aktualisieren der Auth-Seiten-Einstellungen ist ein Fehler aufgetreten", "authPageErrorUpdate": "Auth Seite kann nicht aktualisiert werden", "authPageDomainUpdated": "Domain der Authentifizierungsseite erfolgreich aktualisiert", "healthCheckNotAvailable": "Lokal", "rewritePath": "Pfad umschreiben", "rewritePathDescription": "Optional den Pfad umschreiben, bevor er an das Ziel weitergeleitet wird.", "continueToApplication": "Weiter zur Anwendung", "checkingInvite": "Einladung wird überprüft", "setResourceHeaderAuth": "setResourceHeaderAuth", "resourceHeaderAuthRemove": "Header-Auth entfernen", "resourceHeaderAuthRemoveDescription": "Header-Authentifizierung erfolgreich entfernt.", "resourceErrorHeaderAuthRemove": "Fehler beim Entfernen der Header-Authentifizierung", "resourceErrorHeaderAuthRemoveDescription": "Die Headerauthentifizierung für die Ressource konnte nicht entfernt werden.", "resourceHeaderAuthProtectionEnabled": "Header-Authentifizierung aktiviert", "resourceHeaderAuthProtectionDisabled": "Header-Authentifizierung deaktiviert", "headerAuthRemove": "Header-Auth entfernen", "headerAuthAdd": "Header-Auth hinzufügen", "resourceErrorHeaderAuthSetup": "Fehler beim Setzen der Header-Authentifizierung", "resourceErrorHeaderAuthSetupDescription": "Konnte Header-Authentifizierung für die Ressource nicht festlegen.", "resourceHeaderAuthSetup": "Header-Authentifizierung erfolgreich festgelegt", "resourceHeaderAuthSetupDescription": "Header-Authentifizierung wurde erfolgreich festgelegt.", "resourceHeaderAuthSetupTitle": "Header-Authentifizierung festlegen", "resourceHeaderAuthSetupTitleDescription": "Legen Sie die grundlegenden Authentifizierungsdaten (Benutzername und Passwort) fest, um diese Ressource mit HTTP-Header-Authentifizierung zu schützen. Greifen Sie auf sie mit dem Format https://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Header-Authentifizierung festlegen", "actionSetResourceHeaderAuth": "Header-Authentifizierung festlegen", "enterpriseEdition": "Enterprise Edition", "unlicensed": "Nicht lizenziert", "beta": "Beta", "manageUserDevices": "Benutzer-Geräte", "manageUserDevicesDescription": "Geräte anschauen und verwalten, die Benutzer für private Verbindungen zu Ressourcen verwenden", "downloadClientBannerTitle": "Pangolin Client herunterladen", "downloadClientBannerDescription": "Laden Sie den Pangolin Client für Ihr System herunter, um sich mit dem Pangolin Netzwerk zu verbinden und privat auf Ressourcen zuzugreifen.", "manageMachineClients": "Maschinen-Clients verwalten", "manageMachineClientsDescription": "Erstelle und verwalte Clients, die Server und Systeme nutzen, um privat mit Ressourcen zu verbinden", "machineClientsBannerTitle": "Server & Automatisierte Systeme", "machineClientsBannerDescription": "Maschinelle Clients sind für Server und automatisierte Systeme, die nicht einem bestimmten Benutzer zugeordnet sind. Sie authentifizieren sich mit einer ID und einem Geheimnis und können mit Pangolin CLI, Olm CLI oder Olm als Container laufen.", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Olm Container", "clientsTableUserClients": "Benutzer", "clientsTableMachineClients": "Maschine", "licenseTableValidUntil": "Gültig bis", "saasLicenseKeysSettingsTitle": "Enterprise-Lizenzen", "saasLicenseKeysSettingsDescription": "Erstelle und verwalte Enterprise-Lizenzschlüssel für selbst gehostete Pangolin-Instanzen", "sidebarEnterpriseLicenses": "Lizenzen", "generateLicenseKey": "Lizenzschlüssel generieren", "generateLicenseKeyForm": { "validation": { "emailRequired": "Bitte geben Sie eine gültige E-Mail-Adresse ein", "useCaseTypeRequired": "Bitte wählen Sie einen Anwendungsfall", "firstNameRequired": "Vorname ist erforderlich", "lastNameRequired": "Nachname ist erforderlich", "primaryUseRequired": "Bitte beschreiben Sie Ihre primäre Verwendung", "jobTitleRequiredBusiness": "Job-Titel ist für die geschäftliche Nutzung erforderlich", "industryRequiredBusiness": "Industrie ist für die geschäftliche Nutzung erforderlich", "stateProvinceRegionRequired": "Bundesland/Provinz/Region ist erforderlich", "postalZipCodeRequired": "Postleitzahl ist erforderlich", "companyNameRequiredBusiness": "Firmenname ist erforderlich für den geschäftlichen Gebrauch", "countryOfResidenceRequiredBusiness": "Land des Wohnsitzes ist für die geschäftliche Nutzung erforderlich", "countryRequiredPersonal": "Land ist für den persönlichen Gebrauch erforderlich", "agreeToTermsRequired": "Sie müssen den Bedingungen zustimmen", "complianceConfirmationRequired": "Sie müssen die Einhaltung der Fossorial Commercial License bestätigen" }, "useCaseOptions": { "personal": { "title": "Persönliche Nutzung", "description": "Für den individuellen, nicht-kommerziellen Gebrauch wie Lernen, persönliche Projekte oder Experimente." }, "business": { "title": "Business-Nutzung", "description": "Für den Einsatz innerhalb von Organisationen, Unternehmen oder kommerziellen oder einkommensfördernden Aktivitäten." } }, "steps": { "emailLicenseType": { "title": "E-Mail & Lizenztyp", "description": "Geben Sie Ihre E-Mail ein und wählen Sie Ihren Lizenztyp" }, "personalInformation": { "title": "Persönliche Informationen", "description": "Erzählen Sie uns über sich selbst" }, "contactInformation": { "title": "Kontaktinformationen", "description": "Ihre Kontaktdaten" }, "termsGenerate": { "title": "Begriffe & Generieren", "description": "Bedingungen überprüfen und akzeptieren, um Ihre Lizenz zu generieren" } }, "alerts": { "commercialUseDisclosure": { "title": "Verwendungsanzeige", "description": "Wählen Sie die Lizenz-Ebene, die Ihre beabsichtigte Nutzung genau widerspiegelt. Die Persönliche Lizenz erlaubt die freie Nutzung der Software für individuelle, nicht-kommerzielle oder kleine kommerzielle Aktivitäten mit jährlichen Brutto-Einnahmen von 100.000 USD. Über diese Grenzen hinausgehende Verwendungszwecke – einschließlich der Verwendung innerhalb eines Unternehmens, einer Organisation, oder eine andere umsatzgenerierende Umgebung — erfordert eine gültige Enterprise-Lizenz und die Zahlung der Lizenzgebühr. Alle Benutzer, ob Personal oder Enterprise, müssen die Fossorial Commercial License Bedingungen einhalten." }, "trialPeriodInformation": { "title": "Testperiode Information", "description": "Dieser Lizenzschlüssel ermöglicht Enterprise-Funktionen für einen 7-tägigen Bewertungszeitraum. Der fortgesetzte Zugriff auf kostenpflichtige Funktionen über den Bewertungszeitraum hinaus erfordert die Aktivierung unter einer gültigen Personen- oder Enterprise-Lizenz. Für die Enterprise-Lizenzierung wenden Sie sich bitte an sales@pangolin.net." } }, "form": { "useCaseQuestion": "Benutzen Sie Pangolin für den persönlichen oder geschäftlichen Gebrauch?", "firstName": "Vorname", "lastName": "Nachname", "jobTitle": "Job Titel", "primaryUseQuestion": "Wofür planen Sie in erster Linie Pangolin zu benutzen?", "industryQuestion": "Was ist Ihre Branche?", "prospectiveUsersQuestion": "Wie viele Interessenten erwarten Sie?", "prospectiveSitesQuestion": "Wie viele potenzielle Standorte (Tunnel) erwarten Sie?", "companyName": "Firmenname", "countryOfResidence": "Land des Wohnsitzes", "stateProvinceRegion": "Bundesland / Provinz / Region", "postalZipCode": "Postleitzahl", "companyWebsite": "Firmen-Webseite", "companyPhoneNumber": "Firmennummer", "country": "Land", "phoneNumberOptional": "Telefonnummer (optional)", "complianceConfirmation": "Ich bestätige, dass die von mir übermittelten Informationen korrekt sind und dass ich im Einklang mit der Fossorial Commercial License bin. Die Meldung ungenauer Informationen oder die falsche Identifizierung der Nutzung des Produkts stellt eine Verletzung der Lizenz dar und kann dazu führen, dass Ihr Schlüssel widerrufen wird." }, "buttons": { "close": "Schließen", "previous": "Vorherige", "next": "Nächste", "generateLicenseKey": "Lizenzschlüssel generieren" }, "toasts": { "success": { "title": "Lizenzschlüssel erfolgreich erstellt", "description": "Ihr Lizenzschlüssel wurde generiert und kann verwendet werden." }, "error": { "title": "Fehler beim Generieren des Lizenzschlüssels", "description": "Beim Generieren des Lizenzschlüssels ist ein Fehler aufgetreten." } } }, "newPricingLicenseForm": { "title": "Lizenz erhalten", "description": "Wählen Sie einen Plan und teilen Sie uns mit, wie Sie Pangolin verwenden möchten.", "chooseTier": "Wählen Sie Ihren Plan", "viewPricingLink": "Siehe Preise, Funktionen und Limits", "tiers": { "starter": { "title": "Starter", "description": "Enterprise Features, 25 Benutzer, 25 Sites und Community-Unterstützung." }, "scale": { "title": "Maßstab", "description": "Enterprise Features, 50 Benutzer, 50 Sites und Prioritätsunterstützung." } }, "personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz — keine Kasse)", "buttons": { "continueToCheckout": "Weiter zur Kasse" }, "toasts": { "checkoutError": { "title": "Checkout-Fehler", "description": "Kasse konnte nicht gestartet werden. Bitte versuchen Sie es erneut." } } }, "priority": "Priorität", "priorityDescription": "Die Routen mit höherer Priorität werden zuerst ausgewertet. Priorität = 100 bedeutet automatische Bestellung (Systementscheidung). Verwenden Sie eine andere Nummer, um manuelle Priorität zu erzwingen.", "instanceName": "Instanzname", "pathMatchModalTitle": "Pfad anpassen konfigurieren", "pathMatchModalDescription": "Legen Sie fest, wie eingehende Anfragen basierend auf ihrem Pfad übereinstimmen sollen.", "pathMatchType": "Übereinstimmungstyp", "pathMatchPrefix": "Präfix", "pathMatchExact": "Exakt", "pathMatchRegex": "Regex", "pathMatchValue": "Pfadwert", "clear": "Leeren", "saveChanges": "Änderungen speichern", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/Pfad", "pathMatchPrefixHelp": "Beispiel: /api trifft /api, /api/users etc.", "pathMatchExactHelp": "Beispiel: /api passt nur auf /api", "pathMatchRegexHelp": "Beispiel: ^/api/.* entspricht /api/anything", "pathRewriteModalTitle": "Pfad Rewriting konfigurieren", "pathRewriteModalDescription": "Transformieren Sie den übereinstimmenden Pfad bevor Sie zum Ziel weiterleiten.", "pathRewriteType": "Rewrite Typ", "pathRewritePrefixOption": "Präfix ersetzen", "pathRewriteExactOption": "Exakt - Gesamten Pfad ersetzen", "pathRewriteRegexOption": "Regex - Musterersetzung", "pathRewriteStripPrefixOption": "Präfix entfernen", "pathRewriteValue": "Wert umschreiben", "pathRewriteRegexPlaceholder": "/neu/$1", "pathRewriteDefaultPlaceholder": "/new-path", "pathRewritePrefixHelp": "Ersetze das passende Präfix mit diesem Wert", "pathRewriteExactHelp": "Ersetze den gesamten Pfad mit diesem Wert, wenn der Pfad genau zutrifft", "pathRewriteRegexHelp": "Capture-Gruppen wie $1, $2 zum Ersetzen verwenden", "pathRewriteStripPrefixHelp": "Leer lassen, um Präfix zu entfernen oder neues Präfix anzugeben", "pathRewritePrefix": "Präfix", "pathRewriteExact": "Exakt", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Entfernen", "pathRewriteStripLabel": "entfernen", "sidebarEnableEnterpriseLicense": "Enterprise-Lizenz aktivieren", "cannotbeUndone": "Dies kann nicht rückgängig gemacht werden.", "toConfirm": "bestätigen.", "deleteClientQuestion": "Sind Sie sicher, dass Sie den Client von dem Standort und der Organisation entfernen möchten?", "clientMessageRemove": "Nach dem Entfernen kann sich der Client nicht mehr mit dem Standort verbinden.", "sidebarLogs": "Logs", "request": "Anfrage", "requests": "Anfragen", "logs": "Logs", "logsSettingsDescription": "Protokolle aus dieser Organisation überwachen", "searchLogs": "Logs suchen...", "action": "Aktion", "actor": "Akteur", "timestamp": "Zeitstempel", "accessLogs": "Zugriffsprotokolle", "exportCsv": "CSV exportieren", "exportError": "Unbekannter Fehler beim Exportieren von CSV", "exportCsvTooltip": "Innerhalb des Zeitraums", "actorId": "Akteur-ID", "allowedByRule": "Erlaubt durch Regel", "allowedNoAuth": "Keine Auth erlaubt", "validAccessToken": "Gültiges Zugriffstoken", "validHeaderAuth": "Gültige Header-Authentifizierung", "validPincode": "Gültiger PIN-Code", "validPassword": "Gültiges Passwort", "validEmail": "Gültige E-Mail-Adresse", "validSSO": "Gültige SSO-Anmeldung", "resourceBlocked": "Ressource blockiert", "droppedByRule": "Abgelegt durch Regel", "noSessions": "Keine Sitzungen", "temporaryRequestToken": "Temporäres Anfrage-Token", "noMoreAuthMethods": "Keine gültige Authentifizierungsmethode verfügbar", "ip": "IP", "reason": "Grund", "requestLogs": "Logs anfordern", "requestAnalytics": "Anfrage-Analyse anzeigen", "host": "Host", "location": "Standort", "actionLogs": "Aktionsprotokolle", "sidebarLogsRequest": "Logs anfordern", "sidebarLogsAccess": "Zugriffsprotokolle", "sidebarLogsAction": "Aktionsprotokolle", "logRetention": "Log-Speicherung", "logRetentionDescription": "Verwalten, wie lange verschiedene Logs für diese Organisation gespeichert werden oder deaktivieren", "requestLogsDescription": "Detaillierte Request-Logs für Ressourcen in dieser Organisation anzeigen", "requestAnalyticsDescription": "Detaillierte Anfrage-Analyse für Ressourcen in dieser Organisation anzeigen", "logRetentionRequestLabel": "Log-Speicherung anfordern", "logRetentionRequestDescription": "Wie lange sollen Request-Logs gespeichert werden", "logRetentionAccessLabel": "Zugriffsprotokoll-Speicherung", "logRetentionAccessDescription": "Wie lange Zugriffsprotokolle beibehalten werden sollen", "logRetentionActionLabel": "Aktionsprotokoll-Speicherung", "logRetentionActionDescription": "Dauer des Action-Logs", "logRetentionDisabled": "Deaktiviert", "logRetention3Days": "3 Tage", "logRetention7Days": "7 Tage", "logRetention14Days": "14 Tage", "logRetention30Days": "30 Tage", "logRetention90Days": "90 Tage", "logRetentionForever": "Für immer", "logRetentionEndOfFollowingYear": "Ende des folgenden Jahres", "actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen", "accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen", "licenseRequiredToUse": "Um diese Funktion nutzen zu können, ist eine Enterprise Edition Lizenz erforderlich. Diese Funktion ist auch in der Pangolin Cloud verfügbar.", "ossEnterpriseEditionRequired": "Um diese Funktion nutzen zu können, ist die Enterprise Edition erforderlich. Diese Funktion ist auch in der Pangolin Cloud verfügbar.", "certResolver": "Zertifikatsauflöser", "certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.", "selectCertResolver": "Zertifikatsauflöser auswählen", "enterCustomResolver": "Eigenen Auflöser eingeben", "preferWildcardCert": "Wildcard-Zertifikat bevorzugen", "unverified": "Nicht verifiziert", "domainSetting": "Domänen-Einstellungen", "domainSettingDescription": "Einstellungen für die Domain konfigurieren", "preferWildcardCertDescription": "Versuch, ein Wildcard Zertifikat zu generieren (erfordert einen richtig konfigurierten Zertifikats-Resolver).", "recordName": "Name des Datensatzes", "auto": "Auto", "TTL": "TTL", "howToAddRecords": "So kann man Datensätze hinzufügen", "dnsRecord": "DNS-Einträge", "required": "Benötigt", "domainSettingsUpdated": "Domain-Einstellungen erfolgreich aktualisiert", "orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt", "loadingDNSRecords": "Lade DNS-Einträge...", "olmUpdateAvailableInfo": "Eine aktualisierte Version von Olm ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für die beste Erfahrung.", "client": "Client", "proxyProtocol": "Proxy-Protokoll-Einstellungen", "proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP-Dienste zu erhalten.", "enableProxyProtocol": "Proxy-Protokoll aktivieren", "proxyProtocolInfo": "Client-IP-Adressen für TCP-Backends beibehalten", "proxyProtocolVersion": "Proxy-Protokollversion", "version1": " Version 1 (empfohlen)", "version2": "Version 2", "versionDescription": "Die Version 1 ist textbasiert und unterstützt die Version 2, ist binär und effizienter, aber weniger kompatibel.", "warning": "Warnung", "proxyProtocolWarning": "Die Backend-Anwendung muss so konfiguriert sein, dass Proxy-Protokoll-Verbindungen akzeptiert werden. Wenn Ihr Backend das Proxy-Protokoll nicht unterstützt, wird das Aktivieren aller Verbindungen unterbrochen, so dass Sie dies nur aktivieren, wenn Sie wissen, was Sie tun. Stellen Sie sicher, dass Sie Ihr Backend so konfigurieren, dass es Proxy-Protokoll-Header von Traefik vertraut.", "restarting": "Neustarten...", "manual": "Manuell", "messageSupport": "Nachrichtenunterstützung", "supportNotAvailableTitle": "Support nicht verfügbar", "supportNotAvailableDescription": "Support ist momentan nicht verfügbar. Sie können eine E-Mail an support@pangolin.net senden.", "supportRequestSentTitle": "Supportanfrage gesendet", "supportRequestSentDescription": "Ihre Nachricht wurde erfolgreich gesendet.", "supportRequestFailedTitle": "Senden der Anfrage fehlgeschlagen", "supportRequestFailedDescription": "Beim Senden Ihrer Supportanfrage ist ein Fehler aufgetreten.", "supportSubjectRequired": "Betreff ist erforderlich", "supportSubjectMaxLength": "Betreff muss mindestens 255 Zeichen lang sein", "supportMessageRequired": "Nachricht ist erforderlich", "supportReplyTo": "Antwort an", "supportSubject": "Betreff", "supportSubjectPlaceholder": "Betreff eingeben", "supportMessage": "Nachricht", "supportMessagePlaceholder": "Geben Sie Ihre Nachricht ein", "supportSending": "Senden...", "supportSend": "Senden", "supportMessageSent": "Nachricht gesendet!", "supportWillContact": "Wir werden in Kürze kontaktieren!", "selectLogRetention": "Log-Speicherung auswählen", "terms": "Begriffe", "privacy": "Privatsphäre", "security": "Sicherheit", "docs": "Texte", "deviceActivation": "Geräte-Aktivierung", "deviceCodeInvalidFormat": "Code muss 9 Zeichen lang sein (z.B. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Ungültiger oder abgelaufener Code", "deviceCodeVerifyFailed": "Fehler beim Überprüfen des Gerätecodes", "deviceCodeValidating": "Überprüfe Gerätecode...", "deviceCodeVerifying": "Geräteautorisierung wird überprüft...", "signedInAs": "Angemeldet als", "deviceCodeEnterPrompt": "Geben Sie den auf dem Gerät angezeigten Code ein", "continue": "Weiter", "deviceUnknownLocation": "Unbekannter Ort", "deviceAuthorizationRequested": "Diese Autorisierung wurde von {location} auf {date}angefordert. Stellen Sie sicher, dass Sie diesem Gerät vertrauen, da es Zugriff auf das Konto erhält.", "deviceLabel": "Gerät: {deviceName}", "deviceWantsAccess": "möchte auf Ihr Konto zugreifen", "deviceExistingAccess": "Existierender Zugriff:", "deviceFullAccess": "Voller Zugriff auf Ihr Konto", "deviceOrganizationsAccess": "Zugriff auf alle Organisationen, auf die Ihr Konto Zugriff hat", "deviceAuthorize": "{applicationName} autorisieren", "deviceConnected": "Gerät verbunden!", "deviceAuthorizedMessage": "Gerät ist berechtigt, auf Ihr Konto zuzugreifen. Bitte kehren Sie zur Client-Anwendung zurück.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "Geräte anzeigen", "viewDevicesDescription": "Verwalten Sie Ihre verbundenen Geräte", "noDevices": "Keine Geräte gefunden", "dateCreated": "Erstellungsdatum", "unnamedDevice": "Unbenanntes Gerät", "deviceQuestionRemove": "Sind Sie sicher, dass Sie dieses Gerät löschen möchten?", "deviceMessageRemove": "Diese Aktion kann nicht rückgängig gemacht werden.", "deviceDeleteConfirm": "Gerät löschen", "deleteDevice": "Gerät löschen", "errorLoadingDevices": "Fehler beim Laden der Geräte", "failedToLoadDevices": "Fehler beim Laden der Geräte", "deviceDeleted": "Gerät gelöscht", "deviceDeletedDescription": "Das Gerät wurde erfolgreich gelöscht.", "errorDeletingDevice": "Fehler beim Löschen des Geräts", "failedToDeleteDevice": "Gerät konnte nicht gelöscht werden", "showColumns": "Spalten anzeigen", "hideColumns": "Spalten ausblenden", "columnVisibility": "Spaltensichtbarkeit", "toggleColumn": "{columnName} Spalte umschalten", "allColumns": "Alle Spalten", "defaultColumns": "Standardspalten", "customizeView": "Ansicht anpassen", "viewOptions": "Optionen anzeigen", "selectAll": "Alle auswählen", "selectNone": "Nichts auswählen", "selectedResources": "Ausgewählte Ressourcen", "enableSelected": "Ausgewählte aktivieren", "disableSelected": "Ausgewählte deaktivieren", "checkSelectedStatus": "Status der Auswahl überprüfen", "clients": "Clients", "accessClientSelect": "Maschinen-Clients auswählen", "resourceClientDescription": "Maschinelle Clients die auf diese Ressource zugreifen können", "regenerate": "Neu generieren", "credentials": "Zugangsdaten", "savecredentials": "Zugangsdaten speichern", "regenerateCredentialsButton": "Zugangsdaten neu generieren", "regenerateCredentials": "Zugangsdaten neu generieren", "generatedcredentials": "Generierte Zugangsdaten", "copyandsavethesecredentials": "Diese Zugangsdaten kopieren und speichern", "copyandsavethesecredentialsdescription": "Diese Zugangsdaten werden nach dem Verlassen dieser Seite nicht mehr angezeigt. Speichern Sie sie jetzt sicher.", "credentialsSaved": "Zugangsdaten gespeichert", "credentialsSavedDescription": "Zugangsdaten wurden neu erstellt und erfolgreich gespeichert.", "credentialsSaveError": "Fehler beim Speichern der Zugangsdaten", "credentialsSaveErrorDescription": "Beim Erneuern und Speichern der Zugangsdaten ist ein Fehler aufgetreten.", "regenerateCredentialsWarning": "Das erneute Erzeugen von Anmeldedaten wird die vorhergehenden ungültig machen und eine Trennung der Verbindung verursachen. Stellen Sie sicher, dass Sie Konfigurationen aktualisieren, die diese Zugangsdaten verwenden.", "confirm": "Bestätigen", "regenerateCredentialsConfirmation": "Sind Sie sicher, dass Sie die Zugangsdaten neu generieren möchten?", "endpoint": "Endpunkt", "Id": "ID", "SecretKey": "Geheimer Schlüssel", "niceId": "Schöne ID", "niceIdUpdated": "Schöne ID aktualisiert", "niceIdUpdatedSuccessfully": "Nice ID erfolgreich aktualisiert", "niceIdUpdateError": "Fehler beim Aktualisieren der Nizza-ID", "niceIdUpdateErrorDescription": "Beim Aktualisieren der Nizza-ID ist ein Fehler aufgetreten.", "niceIdCannotBeEmpty": "Nizza-ID darf nicht leer sein", "enterIdentifier": "Identifikator eingeben", "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Nicht du? Verwenden Sie ein anderes Konto.", "deviceLoginDeviceRequestingAccessToAccount": "Ein Gerät fordert Zugriff auf dieses Konto an.", "loginSelectAuthenticationMethod": "Wählen Sie eine Authentifizierungsmethode aus, um fortzufahren.", "noData": "Keine Daten", "machineClients": "Maschinen-Clients", "install": "Installieren", "run": "Ausführen", "clientNameDescription": "Der Anzeigename des Clients, der später geändert werden kann.", "clientAddress": "Clientadresse (Erweitert)", "setupFailedToFetchSubnet": "Fehler beim Abrufen des Standard-Subnetzes", "setupSubnetAdvanced": "Subnetz (Fortgeschritten)", "setupSubnetDescription": "Das Subnetz für das interne Netzwerk dieser Organisation.", "setupUtilitySubnet": "Utility Subnetz (Erweitert)", "setupUtilitySubnetDescription": "Das Subnetz für die Alias-Adressen und den DNS-Server dieser Organisation.", "siteRegenerateAndDisconnect": "Regenerieren und trennen", "siteRegenerateAndDisconnectConfirmation": "Sind Sie sicher, dass Sie die Anmeldedaten neu generieren und diesen Standort trennen möchten?", "siteRegenerateAndDisconnectWarning": "Dies wird die Anmeldeinformationen neu generieren und den Standort sofort trennen. Der Standort muss mit den neuen Anmeldeinformationen neu gestartet werden.", "siteRegenerateCredentialsConfirmation": "Sind Sie sicher, dass Sie die Zugangsdaten für diese Seite neu generieren möchten?", "siteRegenerateCredentialsWarning": "Dies wird die Anmeldeinformationen neu generieren. Die Seite bleibt verbunden, bis Sie sie manuell neu starten und die neuen Anmeldeinformationen verwenden.", "clientRegenerateAndDisconnect": "Regenerieren und trennen", "clientRegenerateAndDisconnectConfirmation": "Sind Sie sicher, dass Sie die Zugangsdaten neu generieren und diesen Client trennen möchten?", "clientRegenerateAndDisconnectWarning": "Dies wird die Anmeldeinformationen neu generieren und den Client sofort trennen. Der Client muss mit den neuen Anmeldeinformationen neu gestartet werden.", "clientRegenerateCredentialsConfirmation": "Sind Sie sicher, dass Sie die Zugangsdaten für diesen Client neu generieren möchten?", "clientRegenerateCredentialsWarning": "Dies wird die Anmeldeinformationen neu generieren. Der Client bleibt verbunden, bis Sie ihn manuell neu starten und die neuen Anmeldeinformationen verwenden.", "remoteExitNodeRegenerateAndDisconnect": "Regenerieren und trennen", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Sind Sie sicher, dass Sie die Zugangsdaten neu generieren und diesen Remote-Exit-Knoten trennen möchten?", "remoteExitNodeRegenerateAndDisconnectWarning": "Dies wird die Anmeldeinformationen neu generieren und den Remote-Exit-Knoten sofort trennen. Der Remote-Exit-Knoten muss mit den neuen Anmeldeinformationen neu gestartet werden.", "remoteExitNodeRegenerateCredentialsConfirmation": "Sind Sie sicher, dass Sie die Zugangsdaten für diesen Remote-Exit-Knoten neu generieren möchten?", "remoteExitNodeRegenerateCredentialsWarning": "Dies wird die Anmeldeinformationen neu generieren. Der Remote-Exit-Knoten bleibt verbunden, bis Sie ihn manuell neu starten und die neuen Anmeldeinformationen verwenden.", "agent": "Agent", "personalUseOnly": "Nur für persönliche Nutzung", "loginPageLicenseWatermark": "Diese Instanz ist nur für den persönlichen Gebrauch lizenziert.", "instanceIsUnlicensed": "Diese Instanz ist nicht lizenziert.", "portRestrictions": "Port Einschränkungen", "allPorts": "Alle", "custom": "Benutzerdefiniert", "allPortsAllowed": "Alle Ports erlaubt", "allPortsBlocked": "Alle Ports blockiert", "tcpPortsDescription": "Legen Sie fest, welche TCP-Ports für diese Ressource erlaubt sind. Benutzen Sie '*' für alle Ports, lassen Sie leer um alle zu blockieren, oder geben Sie eine kommaseparierte Liste von Ports und Bereichen ein (z.B. 80,443,8000-9000).", "udpPortsDescription": "Geben Sie an, welche UDP-Ports für diese Ressource erlaubt sind. Benutzen Sie '*' für alle Ports, lassen Sie leer um alle zu blockieren, oder geben Sie eine kommaseparierte Liste von Ports und Bereichen (z.B. 53,123,500-600) ein.", "organizationLoginPageTitle": "Organisations-Anmeldeseite", "organizationLoginPageDescription": "Die Anmeldeseite für diese Organisation anpassen", "resourceLoginPageTitle": "Ressourcen-Anmeldeseite", "resourceLoginPageDescription": "Anpassen der Anmeldeseite für einzelne Ressourcen", "enterConfirmation": "Bestätigung eingeben", "blueprintViewDetails": "Details", "defaultIdentityProvider": "Standard Identitätsanbieter", "defaultIdentityProviderDescription": "Wenn ein Standard-Identity Provider ausgewählt ist, wird der Benutzer zur Authentifizierung automatisch an den Anbieter weitergeleitet.", "editInternalResourceDialogNetworkSettings": "Netzwerkeinstellungen", "editInternalResourceDialogAccessPolicy": "Zugriffsrichtlinie", "editInternalResourceDialogAddRoles": "Rollen hinzufügen", "editInternalResourceDialogAddUsers": "Nutzer hinzufügen", "editInternalResourceDialogAddClients": "Clients hinzufügen", "editInternalResourceDialogDestinationLabel": "Ziel", "editInternalResourceDialogDestinationDescription": "Geben Sie die Zieladresse für die interne Ressource an. Dies kann ein Hostname, eine IP-Adresse oder ein CIDR-Bereich sein, abhängig vom gewählten Modus. Legen Sie optional einen internen DNS-Alias für eine vereinfachte Identifizierung fest.", "editInternalResourceDialogPortRestrictionsDescription": "Den Zugriff auf bestimmte TCP/UDP-Ports beschränken oder alle Ports erlauben/blockieren.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Zugriffskontrolle", "editInternalResourceDialogAccessControlDescription": "Kontrollieren Sie, welche Rollen, Benutzer und Maschinen-Clients Zugriff auf diese Ressource haben, wenn sie verbunden sind. Admins haben immer Zugriff.", "editInternalResourceDialogPortRangeValidationError": "Der Port-Bereich muss \"*\" für alle Ports sein, oder eine kommaseparierte Liste von Ports und Bereichen (z.B. \"80,443.8000-9000\"). Ports müssen zwischen 1 und 65535 liegen.", "internalResourceAuthDaemonStrategy": "SSH Auth-Daemon Standort", "internalResourceAuthDaemonStrategyDescription": "Wählen Sie aus, wo der SSH-Authentifizierungs-Daemon läuft: auf der Site (Newt) oder auf einem entfernten Host.", "internalResourceAuthDaemonDescription": "Der SSH-Authentifizierungs-Daemon verarbeitet SSH-Schlüsselsignaturen und PAM-Authentifizierung für diese Ressource. Wählen Sie, ob sie auf der Website (Newt) oder auf einem separaten entfernten Host ausgeführt wird. Siehe die Dokumentation für mehr.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Strategie auswählen", "internalResourceAuthDaemonStrategyLabel": "Standort", "internalResourceAuthDaemonSite": "Vor Ort", "internalResourceAuthDaemonSiteDescription": "Der Auth Daemon läuft auf der Seite (Newt).", "internalResourceAuthDaemonRemote": "Entfernter Host", "internalResourceAuthDaemonRemoteDescription": "Der Auth Daemon läuft auf einem Host, der nicht die Site ist.", "internalResourceAuthDaemonPort": "Daemon-Port (optional)", "orgAuthWhatsThis": "Wo finde ich meine Organisations-ID?", "learnMore": "Mehr erfahren", "backToHome": "Zurück zur Startseite", "needToSignInToOrg": "Benötigen Sie den Identitätsanbieter Ihres Unternehmens?", "maintenanceMode": "Wartungsmodus", "maintenanceModeDescription": "Eine Wartungsseite für Besucher anzeigen", "maintenanceModeType": "Art des Wartungsmodus", "showMaintenancePage": "Eine Wartungsseite für Besucher anzeigen", "enableMaintenanceMode": "Wartungsmodus aktivieren", "automatic": "Automatisch", "automaticModeDescription": " Wartungsseite nur anzeigen, wenn alle Backend-Ziele deaktiviert oder ungesund sind. Deine Ressource funktioniert normal, solange mindestens ein Ziel gesund ist.", "forced": "Erzwungen", "forcedModeDescription": "Immer die Wartungsseite anzeigen, unabhängig von der Gesundheit des Backends. Verwenden Sie diese für geplante Wartung, wenn Sie alle Zugriffe verhindern möchten.", "warning:": "Warnung:", "forcedeModeWarning": "Der gesamte Datenverkehr wird zur Wartungsseite weitergeleitet. Ihre Backend-Ressourcen werden keine Anfragen erhalten.", "pageTitle": "Seitentitel", "pageTitleDescription": "Die Hauptüberschrift auf der Wartungsseite", "maintenancePageMessage": "Wartungsmeldung", "maintenancePageMessagePlaceholder": "Wir sind bald wieder da! Unsere Seite wird derzeit planmäßig gewartet.", "maintenancePageMessageDescription": "Detaillierte Meldung zur Erklärung der Wartung", "maintenancePageTimeTitle": "Geschätzte Abschlusszeit (Optional)", "maintenanceTime": "z.B.: 2 Stunden, Nov 1 um 17:00 Uhr", "maintenanceEstimatedTimeDescription": "Wann Sie den Abschluss der Wartung erwarten", "editDomain": "Domain bearbeiten", "editDomainDescription": "Wählen Sie eine Domain für Ihre Ressource", "maintenanceModeDisabledTooltip": "Diese Funktion erfordert eine gültige Lizenz, um sie zu aktivieren.", "maintenanceScreenTitle": "Dienst vorübergehend nicht verfügbar", "maintenanceScreenMessage": "Wir haben derzeit technische Schwierigkeiten. Bitte schauen Sie bald noch einmal vorbei.", "maintenanceScreenEstimatedCompletion": "Geschätzter Abschluss:", "createInternalResourceDialogDestinationRequired": "Ziel ist erforderlich", "available": "Verfügbar", "archived": "Archiviert", "noArchivedDevices": "Keine archivierten Geräte gefunden", "deviceArchived": "Gerät archiviert", "deviceArchivedDescription": "Das Gerät wurde erfolgreich archiviert.", "errorArchivingDevice": "Fehler beim Archivieren des Geräts", "failedToArchiveDevice": "Archivierung des Geräts fehlgeschlagen", "deviceQuestionArchive": "Sind Sie sicher, dass Sie dieses Gerät archivieren möchten?", "deviceMessageArchive": "Das Gerät wird archiviert und aus Ihrer Liste der aktiven Geräte entfernt.", "deviceArchiveConfirm": "Gerät archivieren", "archiveDevice": "Gerät archivieren", "archive": "Archiv", "deviceUnarchived": "Gerät nicht archiviert", "deviceUnarchivedDescription": "Das Gerät wurde erfolgreich deinstalliert.", "errorUnarchivingDevice": "Fehler beim Entarchivieren des Geräts", "failedToUnarchiveDevice": "Fehler beim Entfernen des Geräts", "unarchive": "Archivieren", "archiveClient": "Client archivieren", "archiveClientQuestion": "Sind Sie sicher, dass Sie diesen Client archivieren möchten?", "archiveClientMessage": "Der Client wird archiviert und aus der Liste Ihrer aktiven Clients entfernt.", "archiveClientConfirm": "Client archivieren", "blockClient": "Client sperren", "blockClientQuestion": "Sind Sie sicher, dass Sie diesen Client blockieren möchten?", "blockClientMessage": "Das Gerät wird gezwungen, die Verbindung zu trennen, wenn es gerade verbunden ist. Sie können das Gerät später entsperren.", "blockClientConfirm": "Client sperren", "active": "Aktiv", "usernameOrEmail": "Benutzername oder E-Mail", "selectYourOrganization": "Wählen Sie Ihre Organisation", "signInTo": "Einloggen in", "signInWithPassword": "Mit Passwort fortfahren", "noAuthMethodsAvailable": "Keine Authentifizierungsmethoden für diese Organisation verfügbar.", "enterPassword": "Geben Sie Ihr Passwort ein", "enterMfaCode": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein", "securityKeyRequired": "Bitte verwenden Sie Ihren Sicherheitsschlüssel zum Anmelden.", "needToUseAnotherAccount": "Benötigen Sie ein anderes Konto?", "loginLegalDisclaimer": "Indem Sie auf die Buttons unten klicken, bestätigen Sie, dass Sie gelesen haben, verstehen, und stimmen den Nutzungsbedingungen und Datenschutzrichtlinien zu.", "termsOfService": "Nutzungsbedingungen", "privacyPolicy": "Datenschutzerklärung", "userNotFoundWithUsername": "Kein Benutzer mit diesem Benutzernamen gefunden.", "verify": "Überprüfen", "signIn": "Anmelden", "forgotPassword": "Passwort vergessen?", "orgSignInTip": "Wenn Sie sich vorher angemeldet haben, können Sie Ihren Benutzernamen oder Ihre E-Mail-Adresse eingeben, um sich stattdessen beim Identifikationsprovider Ihrer Organisation zu authentifizieren. Es ist einfacher!", "continueAnyway": "Trotzdem fortfahren", "dontShowAgain": "Nicht mehr anzeigen", "orgSignInNotice": "Wussten Sie schon?", "signupOrgNotice": "Versucht sich anzumelden?", "signupOrgTip": "Versuchen Sie, sich über den Identitätsanbieter Ihrer Organisation anzumelden?", "signupOrgLink": "Melden Sie sich an oder melden Sie sich stattdessen bei Ihrer Organisation an", "verifyEmailLogInWithDifferentAccount": "Anderes Konto verwenden", "logIn": "Anmelden", "deviceInformation": "Geräteinformationen", "deviceInformationDescription": "Informationen über das Gerät und den Agent", "deviceSecurity": "Gerätesicherheit", "deviceSecurityDescription": "Informationen zur Gerätesicherheit", "platform": "Plattform", "macosVersion": "macOS-Version", "windowsVersion": "Windows-Version", "iosVersion": "iOS-Version", "androidVersion": "Android-Version", "osVersion": "OS-Version", "kernelVersion": "Kernel-Version", "deviceModel": "Gerätemodell", "serialNumber": "Seriennummer", "hostname": "Hostname", "firstSeen": "Zuerst gesehen", "lastSeen": "Zuletzt gesehen", "biometricsEnabled": "Biometrie aktiviert", "diskEncrypted": "Festplatte verschlüsselt", "firewallEnabled": "Firewall aktiviert", "autoUpdatesEnabled": "Automatische Updates aktiviert", "tpmAvailable": "TPM verfügbar", "windowsAntivirusEnabled": "Antivirus aktiviert", "macosSipEnabled": "Schutz der Systemintegrität (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Firewall Stealth-Modus", "linuxAppArmorEnabled": "AppRüstung", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "Geräteinformationen und -einstellungen anzeigen", "devicePendingApprovalDescription": "Dieses Gerät wartet auf Freigabe", "deviceBlockedDescription": "Dieses Gerät ist derzeit gesperrt. Es kann keine Verbindung zu anderen Ressourcen herstellen, es sei denn, es entsperrt.", "unblockClient": "Client entsperren", "unblockClientDescription": "Das Gerät wurde entsperrt", "unarchiveClient": "Client dearchivieren", "unarchiveClientDescription": "Das Gerät wurde nicht archiviert", "block": "Blockieren", "unblock": "Entsperren", "deviceActions": "Geräte-Aktionen", "deviceActionsDescription": "Gerätestatus und Zugriff verwalten", "devicePendingApprovalBannerDescription": "Dieses Gerät wartet auf Genehmigung. Es kann sich erst mit Ressourcen verbinden.", "connected": "Verbunden", "disconnected": "Verbindung getrennt", "approvalsEmptyStateTitle": "Gerätezulassungen nicht aktiviert", "approvalsEmptyStateDescription": "Aktiviere Gerätegenehmigungen für Rollen, um Administratorgenehmigungen zu benötigen, bevor Benutzer neue Geräte verbinden können.", "approvalsEmptyStateStep1Title": "Gehe zu Rollen", "approvalsEmptyStateStep1Description": "Navigieren Sie zu den Rolleneinstellungen Ihrer Organisation, um die Gerätefreigaben zu konfigurieren.", "approvalsEmptyStateStep2Title": "Gerätegenehmigungen aktivieren", "approvalsEmptyStateStep2Description": "Bearbeite eine Rolle und aktiviere die Option 'Gerätegenehmigung erforderlich'. Benutzer mit dieser Rolle benötigen Administrator-Genehmigung für neue Geräte.", "approvalsEmptyStatePreviewDescription": "Vorschau: Wenn aktiviert, werden ausstehende Geräteanfragen hier zur Überprüfung angezeigt", "approvalsEmptyStateButtonText": "Rollen verwalten" } ================================================ FILE: messages/en-US.json ================================================ { "setupCreate": "Create the organization, site, and resources", "headerAuthCompatibilityInfo": "Enable this to force a 401 Unauthorized response when an authentication token is missing. This is required for browsers or specific HTTP libraries that do not send credentials without a server challenge.", "headerAuthCompatibility": "Extended compatibility", "setupNewOrg": "New Organization", "setupCreateOrg": "Create Organization", "setupCreateResources": "Create Resources", "setupOrgName": "Organization Name", "orgDisplayName": "This is the display name of the organization.", "orgId": "Organization ID", "setupIdentifierMessage": "This is the unique identifier for the organization.", "setupErrorIdentifier": "Organization ID is already taken. Please choose a different one.", "componentsErrorNoMemberCreate": "You are not currently a member of any organizations. Create an organization to get started.", "componentsErrorNoMember": "You are not currently a member of any organizations.", "welcome": "Welcome!", "welcomeTo": "Welcome to", "componentsCreateOrg": "Create an Organization", "componentsMember": "You're a member of {count, plural, =0 {no organization} one {one organization} other {# organizations}}.", "componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.", "dismiss": "Dismiss", "subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.", "subscriptionViolationViewBilling": "View billing", "componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.", "componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!", "inviteErrorNotValid": "We're sorry, but it looks like the invite you're trying to access has not been accepted or is no longer valid.", "inviteErrorUser": "We're sorry, but it looks like the invite you're trying to access is not for this user.", "inviteLoginUser": "Please make sure you're logged in as the correct user.", "inviteErrorNoUser": "We're sorry, but it looks like the invite you're trying to access is not for a user that exists.", "inviteCreateUser": "Please create an account first.", "goHome": "Go Home", "inviteLogInOtherUser": "Log In as a Different User", "createAnAccount": "Create an Account", "inviteNotAccepted": "Invite Not Accepted", "authCreateAccount": "Create an account to get started", "authNoAccount": "Don't have an account?", "email": "Email", "password": "Password", "confirmPassword": "Confirm Password", "createAccount": "Create Account", "viewSettings": "View Settings", "delete": "Delete", "name": "Name", "online": "Online", "offline": "Offline", "site": "Site", "dataIn": "Data In", "dataOut": "Data Out", "connectionType": "Connection Type", "tunnelType": "Tunnel Type", "local": "Local", "edit": "Edit", "siteConfirmDelete": "Confirm Delete Site", "siteDelete": "Delete Site", "siteMessageRemove": "Once removed the site will no longer be accessible. All targets associated with the site will also be removed.", "siteQuestionRemove": "Are you sure you want to remove the site from the organization?", "siteManageSites": "Manage Sites", "siteDescription": "Create and manage sites to enable connectivity to private networks", "sitesBannerTitle": "Connect Any Network", "sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.", "sitesBannerButtonText": "Install Site Connector", "approvalsBannerTitle": "Approve or Deny Device Access", "approvalsBannerDescription": "Review and approve or deny device access requests from users. When device approvals are required, users must get admin approval before their devices can connect to your organization's resources.", "approvalsBannerButtonText": "Learn More", "siteCreate": "Create Site", "siteCreateDescription2": "Follow the steps below to create and connect a new site", "siteCreateDescription": "Create a new site to start connecting resources", "close": "Close", "siteErrorCreate": "Error creating site", "siteErrorCreateKeyPair": "Key pair or site defaults not found", "siteErrorCreateDefaults": "Site defaults not found", "method": "Method", "siteMethodDescription": "This is how you will expose connections.", "siteLearnNewt": "Learn how to install Newt on your system", "siteSeeConfigOnce": "You will only be able to see the configuration once.", "siteLoadWGConfig": "Loading WireGuard configuration...", "siteDocker": "Expand for Docker Deployment Details", "toggle": "Toggle", "dockerCompose": "Docker Compose", "dockerRun": "Docker Run", "siteLearnLocal": "Local sites do not tunnel, learn more", "siteConfirmCopy": "I have copied the config", "searchSitesProgress": "Search sites...", "siteAdd": "Add Site", "siteInstallNewt": "Install Site", "siteInstallNewtDescription": "Install the site connector for your system", "WgConfiguration": "WireGuard Configuration", "WgConfigurationDescription": "Use the following configuration to connect to the network", "operatingSystem": "Operating System", "commands": "Commands", "recommended": "Recommended", "siteNewtDescription": "For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard.", "siteRunsInDocker": "Runs in Docker", "siteRunsInShell": "Runs in shell on macOS, Linux, and Windows", "siteErrorDelete": "Error deleting site", "siteErrorUpdate": "Failed to update site", "siteErrorUpdateDescription": "An error occurred while updating the site.", "siteUpdated": "Site updated", "siteUpdatedDescription": "The site has been updated.", "siteGeneralDescription": "Configure the general settings for this site", "siteSettingDescription": "Configure the settings on the site", "siteSetting": "{siteName} Settings", "siteNewtTunnel": "Newt Site (Recommended)", "siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.", "siteWg": "Basic WireGuard", "siteWgDescription": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.", "siteWgDescriptionSaas": "Use any WireGuard client to establish a tunnel. Manual NAT setup required.", "siteLocalDescription": "Local resources only. No tunneling.", "siteLocalDescriptionSaas": "Local resources only. No tunneling. Only available on remote nodes.", "siteSeeAll": "See All Sites", "siteTunnelDescription": "Determine how you want to connect to the site", "siteNewtCredentials": "Credentials", "siteNewtCredentialsDescription": "This is how the site will authenticate with the server", "remoteNodeCredentialsDescription": "This is how the remote node will authenticate with the server", "siteCredentialsSave": "Save the Credentials", "siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", "siteInfo": "Site Information", "status": "Status", "shareTitle": "Manage Share Links", "shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources", "shareSearch": "Search share links...", "shareCreate": "Create Share Link", "shareErrorDelete": "Failed to delete link", "shareErrorDeleteMessage": "An error occurred deleting link", "shareDeleted": "Link deleted", "shareDeletedDescription": "The link has been deleted", "shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.", "accessToken": "Access Token", "usageExamples": "Usage Examples", "tokenId": "Token ID", "requestHeades": "Request Headers", "queryParameter": "Query Parameter", "importantNote": "Important Note", "shareImportantDescription": "For security reasons, using headers is recommended over query parameters when possible, as query parameters may be logged in server logs or browser history.", "token": "Token", "shareTokenSecurety": "Keep the access token secure. Do not share it in publicly accessible areas or client-side code.", "shareErrorFetchResource": "Failed to fetch resources", "shareErrorFetchResourceDescription": "An error occurred while fetching the resources", "shareErrorCreate": "Failed to create share link", "shareErrorCreateDescription": "An error occurred while creating the share link", "shareCreateDescription": "Anyone with this link can access the resource", "shareTitleOptional": "Title (optional)", "expireIn": "Expire In", "neverExpire": "Never expire", "shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.", "shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.", "shareAccessHint": "Anyone with this link can access the resource. Share it with care.", "shareTokenUsage": "See Access Token Usage", "createLink": "Create Link", "resourcesNotFound": "No resources found", "resourceSearch": "Search resources", "openMenu": "Open menu", "resource": "Resource", "title": "Title", "created": "Created", "expires": "Expires", "never": "Never", "shareErrorSelectResource": "Please select a resource", "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", "proxyResourcesBannerTitle": "Web-based Public Access", "proxyResourcesBannerDescription": "Public resources are HTTPS or TCP/UDP proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "privateResourcesBannerTitle": "Zero-Trust Private Access", "privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.", "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", "authentication": "Authentication", "protected": "Protected", "notProtected": "Not Protected", "resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", "resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?", "resourceHTTP": "HTTPS Resource", "resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.", "resourceRaw": "Raw TCP/UDP Resource", "resourceRawDescription": "Proxy requests over raw TCP/UDP using a port number.", "resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. REQUIRES THE USE OF A REMOTE NODE.", "resourceCreate": "Create Resource", "resourceCreateDescription": "Follow the steps below to create a new resource", "resourceSeeAll": "See All Resources", "resourceInfo": "Resource Information", "resourceNameDescription": "This is the display name for the resource.", "siteSelect": "Select site", "siteSearch": "Search site", "siteNotFound": "No site found.", "selectCountry": "Select country", "searchCountries": "Search countries...", "noCountryFound": "No country found.", "siteSelectionDescription": "This site will provide connectivity to the target.", "resourceType": "Resource Type", "resourceTypeDescription": "Determine how to access the resource", "resourceHTTPSSettings": "HTTPS Settings", "resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS", "domainType": "Domain Type", "subdomain": "Subdomain", "baseDomain": "Base Domain", "subdomnainDescription": "The subdomain where the resource will be accessible.", "resourceRawSettings": "TCP/UDP Settings", "resourceRawSettingsDescription": "Configure how the resource will be accessed over TCP/UDP", "protocol": "Protocol", "protocolSelect": "Select a protocol", "resourcePortNumber": "Port Number", "resourcePortNumberDescription": "The external port number to proxy requests.", "back": "Back", "cancel": "Cancel", "resourceConfig": "Configuration Snippets", "resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource", "resourceAddEntrypoints": "Traefik: Add Entrypoints", "resourceExposePorts": "Gerbil: Expose Ports in Docker Compose", "resourceLearnRaw": "Learn how to configure TCP/UDP resources", "resourceBack": "Back to Resources", "resourceGoTo": "Go to Resource", "resourceDelete": "Delete Resource", "resourceDeleteConfirm": "Confirm Delete Resource", "visibility": "Visibility", "enabled": "Enabled", "disabled": "Disabled", "general": "General", "generalSettings": "General Settings", "proxy": "Proxy", "internal": "Internal", "rules": "Rules", "resourceSettingDescription": "Configure the settings on the resource", "resourceSetting": "{resourceName} Settings", "alwaysAllow": "Bypass Auth", "alwaysDeny": "Block Access", "passToAuth": "Pass to Auth", "orgSettingsDescription": "Configure the organization's settings", "orgGeneralSettings": "Organization Settings", "orgGeneralSettingsDescription": "Manage the organization's details and configuration", "saveGeneralSettings": "Save General Settings", "saveSettings": "Save Settings", "orgDangerZone": "Danger Zone", "orgDangerZoneDescription": "Once you delete this org, there is no going back. Please be certain.", "orgDelete": "Delete Organization", "orgDeleteConfirm": "Confirm Delete Organization", "orgMessageRemove": "This action is irreversible and will delete all associated data.", "orgMessageConfirm": "To confirm, please type the name of the organization below.", "orgQuestionRemove": "Are you sure you want to remove the organization?", "orgUpdated": "Organization updated", "orgUpdatedDescription": "The organization has been updated.", "orgErrorUpdate": "Failed to update organization", "orgErrorUpdateMessage": "An error occurred while updating the organization.", "orgErrorFetch": "Failed to fetch organizations", "orgErrorFetchMessage": "An error occurred while listing your organizations", "orgErrorDelete": "Failed to delete organization", "orgErrorDeleteMessage": "An error occurred while deleting the organization.", "orgDeleted": "Organization deleted", "orgDeletedMessage": "The organization and its data has been deleted.", "deleteAccount": "Delete Account", "deleteAccountDescription": "Permanently delete your account, all organizations you own, and all data within those organizations. This cannot be undone.", "deleteAccountButton": "Delete Account", "deleteAccountConfirmTitle": "Delete Account", "deleteAccountConfirmMessage": "This will permanently wipe your account, all organizations you own, and all data within those organizations. This cannot be undone.", "deleteAccountConfirmString": "delete account", "deleteAccountSuccess": "Account Deleted", "deleteAccountSuccessMessage": "Your account has been deleted.", "deleteAccountError": "Failed to delete account", "deleteAccountPreviewAccount": "Your Account", "deleteAccountPreviewOrgs": "Organizations you own (and all their data)", "orgMissing": "Organization ID Missing", "orgMissingMessage": "Unable to regenerate invitation without an organization ID.", "accessUsersManage": "Manage Users", "accessUsersDescription": "Invite and manage users with access to this organization", "accessUsersSearch": "Search users...", "accessUserCreate": "Create User", "accessUserRemove": "Remove User", "username": "Username", "identityProvider": "Identity Provider", "role": "Role", "nameRequired": "Name is required", "accessRolesManage": "Manage Roles", "accessRolesDescription": "Create and manage roles for users in the organization", "accessRolesSearch": "Search roles...", "accessRolesAdd": "Add Role", "accessRoleDelete": "Delete Role", "accessApprovalsManage": "Manage Approvals", "accessApprovalsDescription": "View and manage pending approvals for access to this organization", "description": "Description", "inviteTitle": "Open Invitations", "inviteDescription": "Manage invitations for other users to join the organization", "inviteSearch": "Search invitations...", "minutes": "Minutes", "hours": "Hours", "days": "Days", "weeks": "Weeks", "months": "Months", "years": "Years", "day": "{count, plural, one {# day} other {# days}}", "apiKeysTitle": "API Key Information", "apiKeysConfirmCopy2": "You must confirm that you have copied the API key.", "apiKeysErrorCreate": "Error creating API key", "apiKeysErrorSetPermission": "Error setting permissions", "apiKeysCreate": "Generate API Key", "apiKeysCreateDescription": "Generate a new API key for the organization", "apiKeysGeneralSettings": "Permissions", "apiKeysGeneralSettingsDescription": "Determine what this API key can do", "apiKeysList": "New API Key", "apiKeysSave": "Save the API Key", "apiKeysSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", "apiKeysInfo": "The API key is:", "apiKeysConfirmCopy": "I have copied the API key", "generate": "Generate", "done": "Done", "apiKeysSeeAll": "See All API Keys", "apiKeysPermissionsErrorLoadingActions": "Error loading API key actions", "apiKeysPermissionsErrorUpdate": "Error setting permissions", "apiKeysPermissionsUpdated": "Permissions updated", "apiKeysPermissionsUpdatedDescription": "The permissions have been updated.", "apiKeysPermissionsGeneralSettings": "Permissions", "apiKeysPermissionsGeneralSettingsDescription": "Determine what this API key can do", "apiKeysPermissionsSave": "Save Permissions", "apiKeysPermissionsTitle": "Permissions", "apiKeys": "API Keys", "searchApiKeys": "Search API keys...", "apiKeysAdd": "Generate API Key", "apiKeysErrorDelete": "Error deleting API key", "apiKeysErrorDeleteMessage": "Error deleting API key", "apiKeysQuestionRemove": "Are you sure you want to remove the API key from the organization?", "apiKeysMessageRemove": "Once removed, the API key will no longer be able to be used.", "apiKeysDeleteConfirm": "Confirm Delete API Key", "apiKeysDelete": "Delete API Key", "apiKeysManage": "Manage API Keys", "apiKeysDescription": "API keys are used to authenticate with the integration API", "apiKeysSettings": "{apiKeyName} Settings", "userTitle": "Manage All Users", "userDescription": "View and manage all users in the system", "userAbount": "About User Management", "userAbountDescription": "This table displays all root user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their root user object - they will remain in the system. To completely remove a user from the system, you must delete their root user object using the delete action in this table.", "userServer": "Server Users", "userSearch": "Search server users...", "userErrorDelete": "Error deleting user", "userDeleteConfirm": "Confirm Delete User", "userDeleteServer": "Delete User from Server", "userMessageRemove": "The user will be removed from all organizations and be completely removed from the server.", "userQuestionRemove": "Are you sure you want to permanently delete user from the server?", "licenseKey": "License Key", "valid": "Valid", "numberOfSites": "Number of Sites", "licenseKeySearch": "Search license keys...", "licenseKeyAdd": "Add License Key", "type": "Type", "licenseKeyRequired": "License key is required", "licenseTermsAgree": "You must agree to the license terms", "licenseErrorKeyLoad": "Failed to load license keys", "licenseErrorKeyLoadDescription": "An error occurred loading license keys.", "licenseErrorKeyDelete": "Failed to delete license key", "licenseErrorKeyDeleteDescription": "An error occurred deleting license key.", "licenseKeyDeleted": "License key deleted", "licenseKeyDeletedDescription": "The license key has been deleted.", "licenseErrorKeyActivate": "Failed to activate license key", "licenseErrorKeyActivateDescription": "An error occurred while activating the license key.", "licenseAbout": "About Licensing", "communityEdition": "Community Edition", "licenseAboutDescription": "This is for business and enterprise users who are using Pangolin in a commercial environment. If you are using Pangolin for personal use, you can ignore this section.", "licenseKeyActivated": "License key activated", "licenseKeyActivatedDescription": "The license key has been successfully activated.", "licenseErrorKeyRecheck": "Failed to recheck license keys", "licenseErrorKeyRecheckDescription": "An error occurred rechecking license keys.", "licenseErrorKeyRechecked": "License keys rechecked", "licenseErrorKeyRecheckedDescription": "All license keys have been rechecked", "licenseActivateKey": "Activate License Key", "licenseActivateKeyDescription": "Enter a license key to activate it.", "licenseActivate": "Activate License", "licenseAgreement": "By checking this box, you confirm that you have read and agree to the license terms corresponding to the tier associated with your license key.", "fossorialLicense": "View Fossorial Commercial License & Subscription Terms", "licenseMessageRemove": "This will remove the license key and all associated permissions granted by it.", "licenseMessageConfirm": "To confirm, please type the license key below.", "licenseQuestionRemove": "Are you sure you want to delete the license key ?", "licenseKeyDelete": "Delete License Key", "licenseKeyDeleteConfirm": "Confirm Delete License Key", "licenseTitle": "Manage License Status", "licenseTitleDescription": "View and manage license keys in the system", "licenseHost": "Host License", "licenseHostDescription": "Manage the main license key for the host.", "licensedNot": "Not Licensed", "hostId": "Host ID", "licenseReckeckAll": "Recheck All Keys", "licenseSiteUsage": "Sites Usage", "licenseSiteUsageDecsription": "View the number of sites using this license.", "licenseNoSiteLimit": "There is no limit on the number of sites using an unlicensed host.", "licensePurchase": "Purchase License", "licensePurchaseSites": "Purchase Additional Sites", "licenseSitesUsedMax": "{usedSites} of {maxSites} sites used", "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} in system.", "licensePurchaseDescription": "Choose how many sites you want to {selectedMode, select, license {purchase a license for. You can always add more sites later.} other {add to your existing license.}}", "licenseFee": "License fee", "licensePriceSite": "Price per site", "total": "Total", "licenseContinuePayment": "Continue to Payment", "pricingPage": "pricing page", "pricingPortal": "See Purchase Portal", "licensePricingPage": "For the most up-to-date pricing and discounts, please visit the ", "invite": "Invitations", "inviteRegenerate": "Regenerate Invitation", "inviteRegenerateDescription": "Revoke previous invitation and create a new one", "inviteRemove": "Remove Invitation", "inviteRemoveError": "Failed to remove invitation", "inviteRemoveErrorDescription": "An error occurred while removing the invitation.", "inviteRemoved": "Invitation removed", "inviteRemovedDescription": "The invitation for {email} has been removed.", "inviteQuestionRemove": "Are you sure you want to remove the invitation?", "inviteMessageRemove": "Once removed, this invitation will no longer be valid. You can always re-invite the user later.", "inviteMessageConfirm": "To confirm, please type the email address of the invitation below.", "inviteQuestionRegenerate": "Are you sure you want to regenerate the invitation for {email}? This will revoke the previous invitation.", "inviteRemoveConfirm": "Confirm Remove Invitation", "inviteRegenerated": "Invitation Regenerated", "inviteSent": "A new invitation has been sent to {email}.", "inviteSentEmail": "Send email notification to the user", "inviteGenerate": "A new invitation has been generated for {email}.", "inviteDuplicateError": "Duplicate Invite", "inviteDuplicateErrorDescription": "An invitation for this user already exists.", "inviteRateLimitError": "Rate Limit Exceeded", "inviteRateLimitErrorDescription": "You have exceeded the limit of 3 regenerations per hour. Please try again later.", "inviteRegenerateError": "Failed to Regenerate Invitation", "inviteRegenerateErrorDescription": "An error occurred while regenerating the invitation.", "inviteValidityPeriod": "Validity Period", "inviteValidityPeriodSelect": "Select validity period", "inviteRegenerateMessage": "The invitation has been regenerated. The user must access the link below to accept the invitation.", "inviteRegenerateButton": "Regenerate", "expiresAt": "Expires At", "accessRoleUnknown": "Unknown Role", "placeholder": "Placeholder", "userErrorOrgRemove": "Failed to remove user", "userErrorOrgRemoveDescription": "An error occurred while removing the user.", "userOrgRemoved": "User removed", "userOrgRemovedDescription": "The user {email} has been removed from the organization.", "userQuestionOrgRemove": "Are you sure you want to remove this user from the organization?", "userMessageOrgRemove": "Once removed, this user will no longer have access to the organization. You can always re-invite them later, but they will need to accept the invitation again.", "userRemoveOrgConfirm": "Confirm Remove User", "userRemoveOrg": "Remove User from Organization", "users": "Users", "accessRoleMember": "Member", "accessRoleOwner": "Owner", "userConfirmed": "Confirmed", "idpNameInternal": "Internal", "emailInvalid": "Invalid email address", "inviteValidityDuration": "Please select a duration", "accessRoleSelectPlease": "Please select a role", "usernameRequired": "Username is required", "idpSelectPlease": "Please select an identity provider", "idpGenericOidc": "Generic OAuth2/OIDC provider.", "accessRoleErrorFetch": "Failed to fetch roles", "accessRoleErrorFetchDescription": "An error occurred while fetching the roles", "idpErrorFetch": "Failed to fetch identity providers", "idpErrorFetchDescription": "An error occurred while fetching identity providers", "userErrorExists": "User Already Exists", "userErrorExistsDescription": "This user is already a member of the organization.", "inviteError": "Failed to invite user", "inviteErrorDescription": "An error occurred while inviting the user", "userInvited": "User Invited", "userInvitedDescription": "The user has been successfully invited.", "userErrorCreate": "Failed to create user", "userErrorCreateDescription": "An error occurred while creating the user", "userCreated": "User created", "userCreatedDescription": "The user has been successfully created.", "userTypeInternal": "Internal User", "userTypeInternalDescription": "Invite a user to join the organization directly.", "userTypeExternal": "External User", "userTypeExternalDescription": "Create a user with an external identity provider.", "accessUserCreateDescription": "Follow the steps below to create a new user", "userSeeAll": "See All Users", "userTypeTitle": "User Type", "userTypeDescription": "Determine how you want to create the user", "userSettings": "User Information", "userSettingsDescription": "Enter the details for the new user", "inviteEmailSent": "Send invite email to user", "inviteValid": "Valid For", "selectDuration": "Select duration", "selectResource": "Select Resource", "filterByResource": "Filter By Resource", "selectApprovalState": "Select Approval State", "filterByApprovalState": "Filter By Approval State", "approvalListEmpty": "No approvals", "approvalState": "Approval State", "approvalLoadMore": "Load more", "loadingApprovals": "Loading Approvals", "approve": "Approve", "approved": "Approved", "denied": "Denied", "deniedApproval": "Denied Approval", "all": "All", "deny": "Deny", "viewDetails": "View Details", "requestingNewDeviceApproval": "requested a new device", "resetFilters": "Reset Filters", "totalBlocked": "Requests Blocked By Pangolin", "totalRequests": "Total Requests", "requestsByCountry": "Requests By Country", "requestsByDay": "Requests By Day", "blocked": "Blocked", "allowed": "Allowed", "topCountries": "Top Countries", "accessRoleSelect": "Select role", "inviteEmailSentDescription": "An email has been sent to the user with the access link below. They must access the link to accept the invitation.", "inviteSentDescription": "The user has been invited. They must access the link below to accept the invitation.", "inviteExpiresIn": "The invite will expire in {days, plural, one {# day} other {# days}}.", "idpTitle": "Identity Provider", "idpSelect": "Select the identity provider for the external user", "idpNotConfigured": "No identity providers are configured. Please configure an identity provider before creating external users.", "usernameUniq": "This must match the unique username that exists in the selected identity provider.", "emailOptional": "Email (Optional)", "nameOptional": "Name (Optional)", "accessControls": "Access Controls", "userDescription2": "Manage the settings on this user", "accessRoleErrorAdd": "Failed to add user to role", "accessRoleErrorAddDescription": "An error occurred while adding user to the role.", "userSaved": "User saved", "userSavedDescription": "The user has been updated.", "autoProvisioned": "Auto Provisioned", "autoProvisionedDescription": "Allow this user to be automatically managed by identity provider", "accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsSubmit": "Save Access Controls", "roles": "Roles", "accessUsersRoles": "Manage Users & Roles", "accessUsersRolesDescription": "Invite users and add them to roles to manage access to the organization", "key": "Key", "createdAt": "Created At", "proxyErrorInvalidHeader": "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header.", "proxyErrorTls": "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name.", "proxyEnableSSL": "Enable SSL", "proxyEnableSSLDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the targets.", "target": "Target", "configureTarget": "Configure Targets", "targetErrorFetch": "Failed to fetch targets", "targetErrorFetchDescription": "An error occurred while fetching targets", "siteErrorFetch": "Failed to fetch resource", "siteErrorFetchDescription": "An error occurred while fetching resource", "targetErrorDuplicate": "Duplicate target", "targetErrorDuplicateDescription": "A target with these settings already exists", "targetWireGuardErrorInvalidIp": "Invalid target IP", "targetWireGuardErrorInvalidIpDescription": "Target IP must be within the site subnet", "targetsUpdated": "Targets updated", "targetsUpdatedDescription": "Targets and settings updated successfully", "targetsErrorUpdate": "Failed to update targets", "targetsErrorUpdateDescription": "An error occurred while updating targets", "targetTlsUpdate": "TLS settings updated", "targetTlsUpdateDescription": "TLS settings have been updated successfully", "targetErrorTlsUpdate": "Failed to update TLS settings", "targetErrorTlsUpdateDescription": "An error occurred while updating TLS settings", "proxyUpdated": "Proxy settings updated", "proxyUpdatedDescription": "Proxy settings have been updated successfully", "proxyErrorUpdate": "Failed to update proxy settings", "proxyErrorUpdateDescription": "An error occurred while updating proxy settings", "targetAddr": "Host", "targetPort": "Port", "targetProtocol": "Protocol", "targetTlsSettings": "Secure Connection Configuration", "targetTlsSettingsDescription": "Configure SSL/TLS settings for the resource", "targetTlsSettingsAdvanced": "Advanced TLS Settings", "targetTlsSni": "TLS Server Name", "targetTlsSniDescription": "The TLS Server Name to use for SNI. Leave empty to use the default.", "targetTlsSubmit": "Save Settings", "targets": "Targets Configuration", "targetsDescription": "Set up targets to route traffic to backend services", "targetStickySessions": "Enable Sticky Sessions", "targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.", "methodSelect": "Select method", "targetSubmit": "Add Target", "targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.", "targetNoOneDescription": "Adding more than one target above will enable load balancing.", "targetsSubmit": "Save Targets", "addTarget": "Add Target", "targetErrorInvalidIp": "Invalid IP address", "targetErrorInvalidIpDescription": "Please enter a valid IP address or hostname", "targetErrorInvalidPort": "Invalid port", "targetErrorInvalidPortDescription": "Please enter a valid port number", "targetErrorNoSite": "No site selected", "targetErrorNoSiteDescription": "Please select a site for the target", "targetCreated": "Target created", "targetCreatedDescription": "Target has been created successfully", "targetErrorCreate": "Failed to create target", "targetErrorCreateDescription": "An error occurred while creating the target", "tlsServerName": "TLS Server Name", "tlsServerNameDescription": "The TLS server name to use for SNI", "save": "Save", "proxyAdditional": "Additional Proxy Settings", "proxyAdditionalDescription": "Configure how the resource handles proxy settings", "proxyCustomHeader": "Custom Host Header", "proxyCustomHeaderDescription": "The host header to set when proxying requests. Leave empty to use the default.", "proxyAdditionalSubmit": "Save Proxy Settings", "subnetMaskErrorInvalid": "Invalid subnet mask. Must be between 0 and 32.", "ipAddressErrorInvalidFormat": "Invalid IP address format", "ipAddressErrorInvalidOctet": "Invalid IP address octet", "path": "Path", "matchPath": "Match Path", "ipAddressRange": "IP Range", "rulesErrorFetch": "Failed to fetch rules", "rulesErrorFetchDescription": "An error occurred while fetching rules", "rulesErrorDuplicate": "Duplicate rule", "rulesErrorDuplicateDescription": "A rule with these settings already exists", "rulesErrorInvalidIpAddressRange": "Invalid CIDR", "rulesErrorInvalidIpAddressRangeDescription": "Please enter a valid CIDR value", "rulesErrorInvalidUrl": "Invalid URL path", "rulesErrorInvalidUrlDescription": "Please enter a valid URL path value", "rulesErrorInvalidIpAddress": "Invalid IP", "rulesErrorInvalidIpAddressDescription": "Please enter a valid IP address", "rulesErrorUpdate": "Failed to update rules", "rulesErrorUpdateDescription": "An error occurred while updating rules", "rulesUpdated": "Enable Rules", "rulesUpdatedDescription": "Rule evaluation has been updated", "rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)", "rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)", "rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)", "rulesErrorInvalidPriority": "Invalid Priority", "rulesErrorInvalidPriorityDescription": "Please enter a valid priority", "rulesErrorDuplicatePriority": "Duplicate Priorities", "rulesErrorDuplicatePriorityDescription": "Please enter unique priorities", "ruleUpdated": "Rules updated", "ruleUpdatedDescription": "Rules updated successfully", "ruleErrorUpdate": "Operation failed", "ruleErrorUpdateDescription": "An error occurred during the save operation", "rulesPriority": "Priority", "rulesAction": "Action", "rulesMatchType": "Match Type", "value": "Value", "rulesAbout": "About Rules", "rulesAboutDescription": "Rules allow you to control access to the resource based on a set of criteria. You can create rules to allow or deny access based on IP address or URL path.", "rulesActions": "Actions", "rulesActionAlwaysAllow": "Always Allow: Bypass all authentication methods", "rulesActionAlwaysDeny": "Always Deny: Block all requests; no authentication can be attempted", "rulesActionPassToAuth": "Pass to Auth: Allow authentication methods to be attempted", "rulesMatchCriteria": "Matching Criteria", "rulesMatchCriteriaIpAddress": "Match a specific IP address", "rulesMatchCriteriaIpAddressRange": "Match a range of IP addresses in CIDR notation", "rulesMatchCriteriaUrl": "Match a URL path or pattern", "rulesEnable": "Enable Rules", "rulesEnableDescription": "Enable or disable rule evaluation for this resource", "rulesResource": "Resource Rules Configuration", "rulesResourceDescription": "Configure rules to control access to the resource", "ruleSubmit": "Add Rule", "rulesNoOne": "No rules. Add a rule using the form.", "rulesOrder": "Rules are evaluated by priority in ascending order.", "rulesSubmit": "Save Rules", "resourceErrorCreate": "Error creating resource", "resourceErrorCreateDescription": "An error occurred when creating the resource", "resourceErrorCreateMessage": "Error creating resource:", "resourceErrorCreateMessageDescription": "An unexpected error occurred", "sitesErrorFetch": "Error fetching sites", "sitesErrorFetchDescription": "An error occurred when fetching the sites", "domainsErrorFetch": "Error fetching domains", "domainsErrorFetchDescription": "An error occurred when fetching the domains", "none": "None", "unknown": "Unknown", "resources": "Resources", "resourcesDescription": "Resources are proxies to applications running on the private network. Create a resource for any HTTP/HTTPS or raw TCP/UDP service on your private network. Each resource must be connected to a site to enable private, secure connectivity through an encrypted WireGuard tunnel.", "resourcesWireGuardConnect": "Secure connectivity with WireGuard encryption", "resourcesMultipleAuthenticationMethods": "Configure multiple authentication methods", "resourcesUsersRolesAccess": "User and role-based access control", "resourcesErrorUpdate": "Failed to toggle resource", "resourcesErrorUpdateDescription": "An error occurred while updating the resource", "access": "Access", "accessControl": "Access Control", "shareLink": "{resource} Share Link", "resourceSelect": "Select resource", "shareLinks": "Share Links", "share": "Shareable Links", "shareDescription2": "Create shareable links to resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.", "shareEasyCreate": "Easy to create and share", "shareConfigurableExpirationDuration": "Configurable expiration duration", "shareSecureAndRevocable": "Secure and revocable", "nameMin": "Name must be at least {len} characters.", "nameMax": "Name must not be longer than {len} characters.", "sitesConfirmCopy": "Please confirm that you have copied the config.", "unknownCommand": "Unknown command", "newtErrorFetchReleases": "Failed to fetch release info: {err}", "newtErrorFetchLatest": "Error fetching latest release: {err}", "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Secret", "architecture": "Architecture", "sites": "Sites", "siteWgAnyClients": "Use any WireGuard client to connect. You will have to address internal resources using the peer IP.", "siteWgCompatibleAllClients": "Compatible with all WireGuard clients", "siteWgManualConfigurationRequired": "Manual configuration required", "userErrorNotAdminOrOwner": "User is not an admin or owner", "pangolinSettings": "Settings - Pangolin", "accessRoleYour": "Your role:", "accessRoleSelect2": "Select roles", "accessUserSelect": "Select users", "otpEmailEnter": "Enter an email", "otpEmailEnterDescription": "Press enter to add an email after typing it in the input field.", "otpEmailErrorInvalid": "Invalid email address. Wildcard (*) must be the entire local part.", "otpEmailSmtpRequired": "SMTP Required", "otpEmailSmtpRequiredDescription": "SMTP must be enabled on the server to use one-time password authentication.", "otpEmailTitle": "One-time Passwords", "otpEmailTitleDescription": "Require email-based authentication for resource access", "otpEmailWhitelist": "Email Whitelist", "otpEmailWhitelistList": "Whitelisted Emails", "otpEmailWhitelistListDescription": "Only users with these email addresses will be able to access this resource. They will be prompted to enter a one-time password sent to their email. Wildcards (*@example.com) can be used to allow any email address from a domain.", "otpEmailWhitelistSave": "Save Whitelist", "passwordAdd": "Add Password", "passwordRemove": "Remove Password", "pincodeAdd": "Add PIN Code", "pincodeRemove": "Remove PIN Code", "resourceAuthMethods": "Authentication Methods", "resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods", "resourceAuthSettingsSave": "Saved successfully", "resourceAuthSettingsSaveDescription": "Authentication settings have been saved", "resourceErrorAuthFetch": "Failed to fetch data", "resourceErrorAuthFetchDescription": "An error occurred while fetching the data", "resourceErrorPasswordRemove": "Error removing resource password", "resourceErrorPasswordRemoveDescription": "An error occurred while removing the resource password", "resourceErrorPasswordSetup": "Error setting resource password", "resourceErrorPasswordSetupDescription": "An error occurred while setting the resource password", "resourceErrorPincodeRemove": "Error removing resource pincode", "resourceErrorPincodeRemoveDescription": "An error occurred while removing the resource pincode", "resourceErrorPincodeSetup": "Error setting resource PIN code", "resourceErrorPincodeSetupDescription": "An error occurred while setting the resource PIN code", "resourceErrorUsersRolesSave": "Failed to set roles", "resourceErrorUsersRolesSaveDescription": "An error occurred while setting the roles", "resourceErrorWhitelistSave": "Failed to save whitelist", "resourceErrorWhitelistSaveDescription": "An error occurred while saving the whitelist", "resourcePasswordSubmit": "Enable Password Protection", "resourcePasswordProtection": "Password Protection {status}", "resourcePasswordRemove": "Resource password removed", "resourcePasswordRemoveDescription": "The resource password has been removed successfully", "resourcePasswordSetup": "Resource password set", "resourcePasswordSetupDescription": "The resource password has been set successfully", "resourcePasswordSetupTitle": "Set Password", "resourcePasswordSetupTitleDescription": "Set a password to protect this resource", "resourcePincode": "PIN Code", "resourcePincodeSubmit": "Enable PIN Code Protection", "resourcePincodeProtection": "PIN Code Protection {status}", "resourcePincodeRemove": "Resource pincode removed", "resourcePincodeRemoveDescription": "The resource password has been removed successfully", "resourcePincodeSetup": "Resource PIN code set", "resourcePincodeSetupDescription": "The resource pincode has been set successfully", "resourcePincodeSetupTitle": "Set Pincode", "resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource", "resourceRoleDescription": "Admins can always access this resource.", "resourceUsersRoles": "Access Controls", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesSubmit": "Save Access Controls", "resourceWhitelistSave": "Saved successfully", "resourceWhitelistSaveDescription": "Whitelist settings have been saved", "ssoUse": "Use Platform SSO", "ssoUseDescription": "Existing users will only have to log in once for all resources that have this enabled.", "proxyErrorInvalidPort": "Invalid port number", "subdomainErrorInvalid": "Invalid subdomain", "domainErrorFetch": "Error fetching domains", "domainErrorFetchDescription": "An error occurred when fetching the domains", "resourceErrorUpdate": "Failed to update resource", "resourceErrorUpdateDescription": "An error occurred while updating the resource", "resourceUpdated": "Resource updated", "resourceUpdatedDescription": "The resource has been updated successfully", "resourceErrorTransfer": "Failed to transfer resource", "resourceErrorTransferDescription": "An error occurred while transferring the resource", "resourceTransferred": "Resource transferred", "resourceTransferredDescription": "The resource has been transferred successfully", "resourceErrorToggle": "Failed to toggle resource", "resourceErrorToggleDescription": "An error occurred while updating the resource", "resourceVisibilityTitle": "Visibility", "resourceVisibilityTitleDescription": "Completely enable or disable resource visibility", "resourceGeneral": "General Settings", "resourceGeneralDescription": "Configure the general settings for this resource", "resourceEnable": "Enable Resource", "resourceTransfer": "Transfer Resource", "resourceTransferDescription": "Transfer this resource to a different site", "resourceTransferSubmit": "Transfer Resource", "siteDestination": "Destination Site", "searchSites": "Search sites", "countries": "Countries", "accessRoleCreate": "Create Role", "accessRoleCreateDescription": "Create a new role to group users and manage their permissions.", "accessRoleEdit": "Edit Role", "accessRoleEditDescription": "Edit role information.", "accessRoleCreateSubmit": "Create Role", "accessRoleCreated": "Role created", "accessRoleCreatedDescription": "The role has been successfully created.", "accessRoleErrorCreate": "Failed to create role", "accessRoleErrorCreateDescription": "An error occurred while creating the role.", "accessRoleUpdateSubmit": "Update Role", "accessRoleUpdated": "Role updated", "accessRoleUpdatedDescription": "The role has been successfully updated.", "accessApprovalUpdated": "Approval processed", "accessApprovalApprovedDescription": "Set Approval Request decision to approved.", "accessApprovalDeniedDescription": "Set Approval Request decision to denied.", "accessRoleErrorUpdate": "Failed to update role", "accessRoleErrorUpdateDescription": "An error occurred while updating the role.", "accessApprovalErrorUpdate": "Failed to process approval", "accessApprovalErrorUpdateDescription": "An error occurred while processing the approval.", "accessRoleErrorNewRequired": "New role is required", "accessRoleErrorRemove": "Failed to remove role", "accessRoleErrorRemoveDescription": "An error occurred while removing the role.", "accessRoleName": "Role Name", "accessRoleQuestionRemove": "You're about to delete the `{name}` role. You cannot undo this action.", "accessRoleRemove": "Remove Role", "accessRoleRemoveDescription": "Remove a role from the organization", "accessRoleRemoveSubmit": "Remove Role", "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", "licenseTierProfessional": "Professional License", "licenseTierEnterprise": "Enterprise License", "licenseTierPersonal": "Personal License", "licensed": "Licensed", "yes": "Yes", "no": "No", "sitesAdditional": "Additional Sites", "licenseKeys": "License Keys", "sitestCountDecrease": "Decrease site count", "sitestCountIncrease": "Increase site count", "idpManage": "Manage Identity Providers", "idpManageDescription": "View and manage identity providers in the system", "idpGlobalModeBanner": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the admin panel. To enable IdPs per organization, edit the server config and set IdP mode to org. See the docs. If you want to continue using global IdPs and make this disappear from the organization settings, explicitly set the mode to global in the config.", "idpGlobalModeBannerUpgradeRequired": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the admin panel. To use identity providers per organization, you must upgrade to the Enterprise edition.", "idpGlobalModeBannerLicenseRequired": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the admin panel. To use identity providers per organization, an Enterprise license is required.", "idpDeletedDescription": "Identity provider deleted successfully", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Are you sure you want to permanently delete the identity provider?", "idpMessageRemove": "This will remove the identity provider and all associated configurations. Users who authenticate through this provider will no longer be able to log in.", "idpMessageConfirm": "To confirm, please type the name of the identity provider below.", "idpConfirmDelete": "Confirm Delete Identity Provider", "idpDelete": "Delete Identity Provider", "idp": "Identity Providers", "idpSearch": "Search identity providers...", "idpAdd": "Add Identity Provider", "idpClientIdRequired": "Client ID is required.", "idpClientSecretRequired": "Client Secret is required.", "idpErrorAuthUrlInvalid": "Auth URL must be a valid URL.", "idpErrorTokenUrlInvalid": "Token URL must be a valid URL.", "idpPathRequired": "Identifier Path is required.", "idpScopeRequired": "Scopes are required.", "idpOidcDescription": "Configure an OpenID Connect identity provider", "idpCreatedDescription": "Identity provider created successfully", "idpCreate": "Create Identity Provider", "idpCreateDescription": "Configure a new identity provider for user authentication", "idpSeeAll": "See All Identity Providers", "idpSettingsDescription": "Configure the basic information for your identity provider", "idpDisplayName": "A display name for this identity provider", "idpAutoProvisionUsers": "Auto Provision Users", "idpAutoProvisionUsersDescription": "When enabled, users will be automatically created in the system upon first login with the ability to map users to roles and organizations.", "licenseBadge": "EE", "idpType": "Provider Type", "idpTypeDescription": "Select the type of identity provider you want to configure", "idpOidcConfigure": "OAuth2/OIDC Configuration", "idpOidcConfigureDescription": "Configure the OAuth2/OIDC provider endpoints and credentials", "idpClientId": "Client ID", "idpClientIdDescription": "The OAuth2 client ID from the identity provider", "idpClientSecret": "Client Secret", "idpClientSecretDescription": "The OAuth2 client secret from the identity provider", "idpAuthUrl": "Authorization URL", "idpAuthUrlDescription": "The OAuth2 authorization endpoint URL", "idpTokenUrl": "Token URL", "idpTokenUrlDescription": "The OAuth2 token endpoint URL", "idpOidcConfigureAlert": "Important Information", "idpOidcConfigureAlertDescription": "After creating the identity provider, you will need to configure the callback URL in the identity provider's settings. The callback URL will be provided after successful creation.", "idpToken": "Token Configuration", "idpTokenDescription": "Configure how to extract user information from the ID token", "idpJmespathAbout": "About JMESPath", "idpJmespathAboutDescription": "The paths below use JMESPath syntax to extract values from the ID token.", "idpJmespathAboutDescriptionLink": "Learn more about JMESPath", "idpJmespathLabel": "Identifier Path", "idpJmespathLabelDescription": "The path to the user identifier in the ID token", "idpJmespathEmailPathOptional": "Email Path (Optional)", "idpJmespathEmailPathOptionalDescription": "The path to the user's email in the ID token", "idpJmespathNamePathOptional": "Name Path (Optional)", "idpJmespathNamePathOptionalDescription": "The path to the user's name in the ID token", "idpOidcConfigureScopes": "Scopes", "idpOidcConfigureScopesDescription": "Space-separated list of OAuth2 scopes to request", "idpSubmit": "Create Identity Provider", "orgPolicies": "Organization Policies", "idpSettings": "{idpName} Settings", "idpCreateSettingsDescription": "Configure the settings for the identity provider", "roleMapping": "Role Mapping", "orgMapping": "Organization Mapping", "orgPoliciesSearch": "Search organization policies...", "orgPoliciesAdd": "Add Organization Policy", "orgRequired": "Organization is required", "error": "Error", "success": "Success", "orgPolicyAddedDescription": "Policy added successfully", "orgPolicyUpdatedDescription": "Policy updated successfully", "orgPolicyDeletedDescription": "Policy deleted successfully", "defaultMappingsUpdatedDescription": "Default mappings updated successfully", "orgPoliciesAbout": "About Organization Policies", "orgPoliciesAboutDescription": "Organization policies are used to control access to organizations based on the user's ID token. You can specify JMESPath expressions to extract role and organization information from the ID token.", "orgPoliciesAboutDescriptionLink": "See documentation, for more information.", "defaultMappingsOptional": "Default Mappings (Optional)", "defaultMappingsOptionalDescription": "The default mappings are used when when there is not an organization policy defined for an organization. You can specify the default role and organization mappings to fall back to here.", "defaultMappingsRole": "Default Role Mapping", "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsOrg": "Default Organization Mapping", "defaultMappingsOrgDescription": "This expression must return the org ID or true for the user to be allowed to access the organization.", "defaultMappingsSubmit": "Save Default Mappings", "orgPoliciesEdit": "Edit Organization Policy", "org": "Organization", "orgSelect": "Select organization", "orgSearch": "Search org", "orgNotFound": "No org found.", "roleMappingPathOptional": "Role Mapping Path (Optional)", "orgMappingPathOptional": "Organization Mapping Path (Optional)", "orgPolicyUpdate": "Update Policy", "orgPolicyAdd": "Add Policy", "orgPolicyConfig": "Configure access for an organization", "idpUpdatedDescription": "Identity provider updated successfully", "redirectUrl": "Redirect URL", "orgIdpRedirectUrls": "Redirect URLs", "redirectUrlAbout": "About Redirect URL", "redirectUrlAboutDescription": "This is the URL to which users will be redirected after authentication. You need to configure this URL in the identity provider's settings.", "pangolinAuth": "Auth - Pangolin", "verificationCodeLengthRequirements": "Your verification code must be 8 characters.", "errorOccurred": "An error occurred", "emailErrorVerify": "Failed to verify email:", "emailVerified": "Email successfully verified! Redirecting you...", "verificationCodeErrorResend": "Failed to resend verification code:", "verificationCodeResend": "Verification code resent", "verificationCodeResendDescription": "We've resent a verification code to your email address. Please check your inbox.", "emailVerify": "Verify Email", "emailVerifyDescription": "Enter the verification code sent to your email address.", "verificationCode": "Verification Code", "verificationCodeEmailSent": "We sent a verification code to your email address.", "submit": "Submit", "emailVerifyResendProgress": "Resending...", "emailVerifyResend": "Didn't receive a code? Click here to resend", "passwordNotMatch": "Passwords do not match", "signupError": "An error occurred while signing up", "pangolinLogoAlt": "Pangolin Logo", "inviteAlready": "Looks like you've been invited!", "inviteAlreadyDescription": "To accept the invite, you must log in or create an account.", "signupQuestion": "Already have an account?", "login": "Log In", "resourceNotFound": "Resource Not Found", "resourceNotFoundDescription": "The resource you're trying to access does not exist.", "pincodeRequirementsLength": "PIN must be exactly 6 digits", "pincodeRequirementsChars": "PIN must only contain numbers", "passwordRequirementsLength": "Password must be at least 1 character long", "passwordRequirementsTitle": "Password requirements:", "passwordRequirementLength": "At least 8 characters long", "passwordRequirementUppercase": "At least one uppercase letter", "passwordRequirementLowercase": "At least one lowercase letter", "passwordRequirementNumber": "At least one number", "passwordRequirementSpecial": "At least one special character", "passwordRequirementsMet": "✓ Password meets all requirements", "passwordStrength": "Password strength", "passwordStrengthWeak": "Weak", "passwordStrengthMedium": "Medium", "passwordStrengthStrong": "Strong", "passwordRequirements": "Requirements:", "passwordRequirementLengthText": "8+ characters", "passwordRequirementUppercaseText": "Uppercase letter (A-Z)", "passwordRequirementLowercaseText": "Lowercase letter (a-z)", "passwordRequirementNumberText": "Number (0-9)", "passwordRequirementSpecialText": "Special character (!@#$%...)", "passwordsDoNotMatch": "Passwords do not match", "otpEmailRequirementsLength": "OTP must be at least 1 character long", "otpEmailSent": "OTP Sent", "otpEmailSentDescription": "An OTP has been sent to your email", "otpEmailErrorAuthenticate": "Failed to authenticate with email", "pincodeErrorAuthenticate": "Failed to authenticate with pincode", "passwordErrorAuthenticate": "Failed to authenticate with password", "poweredBy": "Powered by", "authenticationRequired": "Authentication Required", "authenticationMethodChoose": "Choose your preferred method to access {name}", "authenticationRequest": "You must authenticate to access {name}", "user": "User", "pincodeInput": "6-digit PIN Code", "pincodeSubmit": "Log in with PIN", "passwordSubmit": "Log In with Password", "otpEmailDescription": "A one-time code will be sent to this email.", "otpEmailSend": "Send One-time Code", "otpEmail": "One-Time Password (OTP)", "otpEmailSubmit": "Submit OTP", "backToEmail": "Back to Email", "noSupportKey": "Server is running without a supporter key. Consider supporting the project!", "accessDenied": "Access Denied", "accessDeniedDescription": "You're not allowed to access this resource. If this is a mistake, please contact the administrator.", "accessTokenError": "Error checking access token", "accessGranted": "Access Granted", "accessUrlInvalid": "Access URL Invalid", "accessGrantedDescription": "You have been granted access to this resource. Redirecting you...", "accessUrlInvalidDescription": "This shared access URL is invalid. Please contact the resource owner for a new URL.", "tokenInvalid": "Invalid token", "pincodeInvalid": "Invalid code", "passwordErrorRequestReset": "Failed to request reset:", "passwordErrorReset": "Failed to reset password:", "passwordResetSuccess": "Password reset successfully! Back to log in...", "passwordReset": "Reset Password", "passwordResetDescription": "Follow the steps to reset your password", "passwordResetSent": "We'll send a password reset code to this email address.", "passwordResetCode": "Reset Code", "passwordResetCodeDescription": "Check your email for the reset code.", "generatePasswordResetCode": "Generate Password Reset Code", "passwordResetCodeGenerated": "Password Reset Code Generated", "passwordResetCodeGeneratedDescription": "Share this code with the user. They can use it to reset their password.", "passwordResetUrl": "Reset URL", "passwordNew": "New Password", "passwordNewConfirm": "Confirm New Password", "changePassword": "Change Password", "changePasswordDescription": "Update your account password", "oldPassword": "Current Password", "newPassword": "New Password", "confirmNewPassword": "Confirm New Password", "changePasswordError": "Failed to change password", "changePasswordErrorDescription": "An error occurred while changing your password", "changePasswordSuccess": "Password Changed Successfully", "changePasswordSuccessDescription": "Your password has been updated successfully", "passwordExpiryRequired": "Password Expiry Required", "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", "changePasswordNow": "Change Password Now", "pincodeAuth": "Authenticator Code", "pincodeSubmit2": "Submit code", "passwordResetSubmit": "Request Reset", "passwordResetAlreadyHaveCode": "Enter Code", "passwordResetSmtpRequired": "Please contact your administrator", "passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.", "passwordBack": "Back to Password", "loginBack": "Go back to main login page", "signup": "Sign up", "loginStart": "Log in to get started", "idpOidcTokenValidating": "Validating OIDC token", "idpOidcTokenResponse": "Validate OIDC token response", "idpErrorOidcTokenValidating": "Error validating OIDC token", "idpConnectingTo": "Connecting to {name}", "idpConnectingToDescription": "Validating your identity", "idpConnectingToProcess": "Connecting...", "idpConnectingToFinished": "Connected", "idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.", "idpErrorNotFound": "IdP not found", "inviteInvalid": "Invalid Invite", "inviteInvalidDescription": "The invite link is invalid.", "inviteErrorWrongUser": "Invite is not for this user", "inviteErrorUserNotExists": "User does not exist. Please create an account first.", "inviteErrorLoginRequired": "You must be logged in to accept an invite", "inviteErrorExpired": "The invite may have expired", "inviteErrorRevoked": "The invite might have been revoked", "inviteErrorTypo": "There could be a typo in the invite link", "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.", "overview": "Overview", "home": "Home", "accessControl": "Access Control", "settings": "Settings", "usersAll": "All Users", "license": "License", "pangolinDashboard": "Dashboard - Pangolin", "noResults": "No results found.", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "Entered Tags", "tagsEnteredDescription": "These are the tags you`ve entered.", "tagsWarnCannotBeLessThanZero": "maxTags and minTags cannot be less than 0", "tagsWarnNotAllowedAutocompleteOptions": "Tag not allowed as per autocomplete options", "tagsWarnInvalid": "Invalid tag as per validateTag", "tagWarnTooShort": "Tag {tagText} is too short", "tagWarnTooLong": "Tag {tagText} is too long", "tagsWarnReachedMaxNumber": "Reached the maximum number of tags allowed", "tagWarnDuplicate": "Duplicate tag {tagText} not added", "supportKeyInvalid": "Invalid Key", "supportKeyInvalidDescription": "Your supporter key is invalid.", "supportKeyValid": "Valid Key", "supportKeyValidDescription": "Your supporter key has been validated. Thank you for your support!", "supportKeyErrorValidationDescription": "Failed to validate supporter key.", "supportKey": "Support Development and Adopt a Pangolin!", "supportKeyDescription": "Purchase a supporter key to help us continue developing Pangolin for the community. Your contribution allows us to commit more time to maintain and add new features to the application for everyone. We will never use this to paywall features. This is separate from any Commercial Edition.", "supportKeyPet": "You will also get to adopt and meet your very own pet Pangolin!", "supportKeyPurchase": "Payments are processed via GitHub. Afterward, you can retrieve your key on", "supportKeyPurchaseLink": "our website", "supportKeyPurchase2": "and redeem it here.", "supportKeyLearnMore": "Learn more.", "supportKeyOptions": "Please select the option that best suits you.", "supportKetOptionFull": "Full Supporter", "forWholeServer": "For the whole server", "lifetimePurchase": "Lifetime purchase", "supporterStatus": "Supporter status", "buy": "Buy", "supportKeyOptionLimited": "Limited Supporter", "forFiveUsers": "For 5 or less users", "supportKeyRedeem": "Redeem Supporter Key", "supportKeyHideSevenDays": "Hide for 7 days", "supportKeyEnter": "Enter Supporter Key", "supportKeyEnterDescription": "Meet your very own pet Pangolin!", "githubUsername": "GitHub Username", "supportKeyInput": "Supporter Key", "supportKeyBuy": "Buy Supporter Key", "logoutError": "Error logging out", "signingAs": "Signed in as", "serverAdmin": "Server Admin", "managedSelfhosted": "Managed Self-Hosted", "otpEnable": "Enable Two-factor", "otpDisable": "Disable Two-factor", "logout": "Log Out", "licenseTierProfessionalRequired": "Professional Edition Required", "licenseTierProfessionalRequiredDescription": "This feature is only available in the Professional Edition.", "actionGetOrg": "Get Organization", "updateOrgUser": "Update Org User", "createOrgUser": "Create Org User", "actionUpdateOrg": "Update Organization", "actionRemoveInvitation": "Remove Invitation", "actionUpdateUser": "Update User", "actionGetUser": "Get User", "actionGetOrgUser": "Get Organization User", "actionListOrgDomains": "List Organization Domains", "actionGetDomain": "Get Domain", "actionCreateOrgDomain": "Create Domain", "actionUpdateOrgDomain": "Update Domain", "actionDeleteOrgDomain": "Delete Domain", "actionGetDNSRecords": "Get DNS Records", "actionRestartOrgDomain": "Restart Domain", "actionCreateSite": "Create Site", "actionDeleteSite": "Delete Site", "actionGetSite": "Get Site", "actionListSites": "List Sites", "actionApplyBlueprint": "Apply Blueprint", "actionListBlueprints": "List Blueprints", "actionGetBlueprint": "Get Blueprint", "setupToken": "Setup Token", "setupTokenDescription": "Enter the setup token from the server console.", "setupTokenRequired": "Setup token is required", "actionUpdateSite": "Update Site", "actionResetSiteBandwidth": "Reset Organization Bandwidth", "actionListSiteRoles": "List Allowed Site Roles", "actionCreateResource": "Create Resource", "actionDeleteResource": "Delete Resource", "actionGetResource": "Get Resource", "actionListResource": "List Resources", "actionUpdateResource": "Update Resource", "actionListResourceUsers": "List Resource Users", "actionSetResourceUsers": "Set Resource Users", "actionSetAllowedResourceRoles": "Set Allowed Resource Roles", "actionListAllowedResourceRoles": "List Allowed Resource Roles", "actionSetResourcePassword": "Set Resource Password", "actionSetResourcePincode": "Set Resource Pincode", "actionSetResourceEmailWhitelist": "Set Resource Email Whitelist", "actionGetResourceEmailWhitelist": "Get Resource Email Whitelist", "actionCreateTarget": "Create Target", "actionDeleteTarget": "Delete Target", "actionGetTarget": "Get Target", "actionListTargets": "List Targets", "actionUpdateTarget": "Update Target", "actionCreateRole": "Create Role", "actionDeleteRole": "Delete Role", "actionGetRole": "Get Role", "actionListRole": "List Roles", "actionUpdateRole": "Update Role", "actionListAllowedRoleResources": "List Allowed Role Resources", "actionInviteUser": "Invite User", "actionRemoveUser": "Remove User", "actionListUsers": "List Users", "actionAddUserRole": "Add User Role", "actionGenerateAccessToken": "Generate Access Token", "actionDeleteAccessToken": "Delete Access Token", "actionListAccessTokens": "List Access Tokens", "actionCreateResourceRule": "Create Resource Rule", "actionDeleteResourceRule": "Delete Resource Rule", "actionListResourceRules": "List Resource Rules", "actionUpdateResourceRule": "Update Resource Rule", "actionListOrgs": "List Organizations", "actionCheckOrgId": "Check ID", "actionCreateOrg": "Create Organization", "actionDeleteOrg": "Delete Organization", "actionListApiKeys": "List API Keys", "actionListApiKeyActions": "List API Key Actions", "actionSetApiKeyActions": "Set API Key Allowed Actions", "actionCreateApiKey": "Create API Key", "actionDeleteApiKey": "Delete API Key", "actionCreateIdp": "Create IDP", "actionUpdateIdp": "Update IDP", "actionDeleteIdp": "Delete IDP", "actionListIdps": "List IDP", "actionGetIdp": "Get IDP", "actionCreateIdpOrg": "Create IDP Org Policy", "actionDeleteIdpOrg": "Delete IDP Org Policy", "actionListIdpOrgs": "List IDP Orgs", "actionUpdateIdpOrg": "Update IDP Org", "actionCreateClient": "Create Client", "actionDeleteClient": "Delete Client", "actionArchiveClient": "Archive Client", "actionUnarchiveClient": "Unarchive Client", "actionBlockClient": "Block Client", "actionUnblockClient": "Unblock Client", "actionUpdateClient": "Update Client", "actionListClients": "List Clients", "actionGetClient": "Get Client", "actionCreateSiteResource": "Create Site Resource", "actionDeleteSiteResource": "Delete Site Resource", "actionGetSiteResource": "Get Site Resource", "actionListSiteResources": "List Site Resources", "actionUpdateSiteResource": "Update Site Resource", "actionListInvitations": "List Invitations", "actionExportLogs": "Export Logs", "actionViewLogs": "View Logs", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", "searchPlaceholder": "Search...", "emptySearchOptions": "No options found", "create": "Create", "orgs": "Organizations", "loginError": "An unexpected error occurred. Please try again.", "loginRequiredForDevice": "Login is required for your device.", "passwordForgot": "Forgot your password?", "otpAuth": "Two-Factor Authentication", "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", "otpAuthSubmit": "Submit Code", "idpContinue": "Or continue with", "otpAuthBack": "Back to Password", "navbar": "Navigation Menu", "navbarDescription": "Main navigation menu for the application", "navbarDocsLink": "Documentation", "otpErrorEnable": "Unable to enable 2FA", "otpErrorEnableDescription": "An error occurred while enabling 2FA", "otpSetupCheckCode": "Please enter a 6-digit code", "otpSetupCheckCodeRetry": "Invalid code. Please try again.", "otpSetup": "Enable Two-factor Authentication", "otpSetupDescription": "Secure your account with an extra layer of protection", "otpSetupScanQr": "Scan this QR code with your authenticator app or enter the secret key manually:", "otpSetupSecretCode": "Authenticator Code", "otpSetupSuccess": "Two-Factor Authentication Enabled", "otpSetupSuccessStoreBackupCodes": "Your account is now more secure. Don't forget to save your backup codes.", "otpErrorDisable": "Unable to disable 2FA", "otpErrorDisableDescription": "An error occurred while disabling 2FA", "otpRemove": "Disable Two-factor Authentication", "otpRemoveDescription": "Disable two-factor authentication for your account", "otpRemoveSuccess": "Two-Factor Authentication Disabled", "otpRemoveSuccessMessage": "Two-factor authentication has been disabled for your account. You can enable it again at any time.", "otpRemoveSubmit": "Disable 2FA", "paginator": "Page {current} of {last}", "paginatorToFirst": "Go to first page", "paginatorToPrevious": "Go to previous page", "paginatorToNext": "Go to next page", "paginatorToLast": "Go to last page", "copyText": "Copy text", "copyTextFailed": "Failed to copy text: ", "copyTextClipboard": "Copy to clipboard", "inviteErrorInvalidConfirmation": "Invalid confirmation", "passwordRequired": "Password is required", "allowAll": "Allow All", "permissionsAllowAll": "Allow All Permissions", "githubUsernameRequired": "GitHub username is required", "supportKeyRequired": "Supporter key is required", "passwordRequirementsChars": "Password must be at least 8 characters", "language": "Language", "verificationCodeRequired": "Code is required", "userErrorNoUpdate": "No user to update", "siteErrorNoUpdate": "No site to update", "resourceErrorNoUpdate": "No resource to update", "authErrorNoUpdate": "No auth info to update", "orgErrorNoUpdate": "No org to update", "orgErrorNoProvided": "No org provided", "apiKeysErrorNoUpdate": "No API key to update", "sidebarOverview": "Overview", "sidebarHome": "Home", "sidebarSites": "Sites", "sidebarApprovals": "Approval Requests", "sidebarResources": "Resources", "sidebarProxyResources": "Public", "sidebarClientResources": "Private", "sidebarAccessControl": "Access Control", "sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarTeam": "Team", "sidebarUsers": "Users", "sidebarAdmin": "Admin", "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", "sidebarShareableLinks": "Links", "sidebarApiKeys": "API Keys", "sidebarSettings": "Settings", "sidebarAllUsers": "All Users", "sidebarIdentityProviders": "Identity Providers", "sidebarLicense": "License", "sidebarClients": "Clients", "sidebarUserDevices": "User Devices", "sidebarMachineClients": "Machines", "sidebarDomains": "Domains", "sidebarGeneral": "Manage", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", "sidebarOrganization": "Organization", "sidebarManagement": "Management", "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", "blueprintAdd": "Add Blueprint", "blueprintGoBack": "See all Blueprints", "blueprintCreate": "Create Blueprint", "blueprintCreateDescription2": "Follow the steps below to create and apply a new blueprint", "blueprintDetails": "Blueprint Details", "blueprintDetailsDescription": "See the result of the applied blueprint and any errors that occurred", "blueprintInfo": "Blueprint Information", "message": "Message", "blueprintContentsDescription": "Define the YAML content describing the infrastructure", "blueprintErrorCreateDescription": "An error occurred when applying the blueprint", "blueprintErrorCreate": "Error creating blueprint", "searchBlueprintProgress": "Search blueprints...", "appliedAt": "Applied At", "source": "Source", "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. Read about how this works in the documentation.", "viewDockerContainers": "View Docker Containers", "containersIn": "Containers in {siteName}", "selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.", "containerName": "Name", "containerImage": "Image", "containerState": "State", "containerNetworks": "Networks", "containerHostnameIp": "Hostname/IP", "containerLabels": "Labels", "containerLabelsCount": "{count, plural, one {# label} other {# labels}}", "containerLabelsTitle": "Container Labels", "containerLabelEmpty": "", "containerPorts": "Ports", "containerPortsMore": "+{count} more", "containerActions": "Actions", "select": "Select", "noContainersMatchingFilters": "No containers found matching the current filters.", "showContainersWithoutPorts": "Show containers without ports", "showStoppedContainers": "Show stopped containers", "noContainersFound": "No containers found. Make sure Docker containers are running.", "searchContainersPlaceholder": "Search across {count} containers...", "searchResultsCount": "{count, plural, one {# result} other {# results}}", "filters": "Filters", "filterOptions": "Filter Options", "filterPorts": "Ports", "filterStopped": "Stopped", "clearAllFilters": "Clear all filters", "columns": "Columns", "toggleColumns": "Toggle Columns", "refreshContainersList": "Refresh containers list", "searching": "Searching...", "noContainersFoundMatching": "No containers found matching \"{filter}\".", "light": "light", "dark": "dark", "system": "system", "theme": "Theme", "subnetRequired": "Subnet is required", "initialSetupTitle": "Initial Server Setup", "initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.", "createAdminAccount": "Create Admin Account", "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", "certificateStatus": "Certificate Status", "loading": "Loading", "loadingAnalytics": "Loading Analytics", "restart": "Restart", "domains": "Domains", "domainsDescription": "Create and manage domains available in the organization", "domainsSearch": "Search domains...", "domainAdd": "Add Domain", "domainAddDescription": "Register a new domain with to the organization", "domainCreate": "Create Domain", "domainCreatedDescription": "Domain created successfully", "domainDeletedDescription": "Domain deleted successfully", "domainQuestionRemove": "Are you sure you want to remove the domain?", "domainMessageRemove": "Once removed, the domain will no longer be associated with the organization.", "domainConfirmDelete": "Confirm Delete Domain", "domainDelete": "Delete Domain", "domain": "Domain", "selectDomainTypeNsName": "Domain Delegation (NS)", "selectDomainTypeNsDescription": "This domain and all its subdomains. Use this when you want to control an entire domain zone.", "selectDomainTypeCnameName": "Single Domain (CNAME)", "selectDomainTypeCnameDescription": "Just this specific domain. Use this for individual subdomains or specific domain entries.", "selectDomainTypeWildcardName": "Wildcard Domain", "selectDomainTypeWildcardDescription": "This domain and its subdomains.", "domainDelegation": "Single Domain", "selectType": "Select a type", "actions": "Actions", "refresh": "Refresh", "refreshError": "Failed to refresh data", "verified": "Verified", "pending": "Pending", "pendingApproval": "Pending Approval", "sidebarBilling": "Billing", "billing": "Billing", "orgBillingDescription": "Manage billing information and subscriptions", "github": "GitHub", "pangolinHosted": "Pangolin Hosted", "fossorial": "Fossorial", "completeAccountSetup": "Complete Account Setup", "completeAccountSetupDescription": "Set your password to get started", "accountSetupSent": "We'll send an account setup code to this email address.", "accountSetupCode": "Setup Code", "accountSetupCodeDescription": "Check your email for the setup code.", "passwordCreate": "Create Password", "passwordCreateConfirm": "Confirm Password", "accountSetupSubmit": "Send Setup Code", "completeSetup": "Complete Setup", "accountSetupSuccess": "Account setup completed! Welcome to Pangolin!", "documentation": "Documentation", "saveAllSettings": "Save All Settings", "saveResourceTargets": "Save Targets", "saveResourceHttp": "Save Proxy Settings", "saveProxyProtocol": "Save Proxy protocol settings", "settingsUpdated": "Settings updated", "settingsUpdatedDescription": "Settings updated successfully", "settingsErrorUpdate": "Failed to update settings", "settingsErrorUpdateDescription": "An error occurred while updating settings", "sidebarCollapse": "Collapse", "sidebarExpand": "Expand", "productUpdateMoreInfo": "{noOfUpdates} more updates", "productUpdateInfo": "{noOfUpdates} updates", "productUpdateWhatsNew": "What's New", "productUpdateTitle": "Product Updates", "productUpdateEmpty": "No updates", "dismissAll": "Dismiss all", "pangolinUpdateAvailable": "Update Available", "pangolinUpdateAvailableInfo": "Version {version} is ready to install", "pangolinUpdateAvailableReleaseNotes": "View Release Notes", "newtUpdateAvailable": "Update Available", "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience.", "domainPickerEnterDomain": "Domain", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Enter the full domain of the resource to see available options.", "domainPickerDescriptionSaas": "Enter a full domain, subdomain, or just a name to see available options", "domainPickerTabAll": "All", "domainPickerTabOrganization": "Organization", "domainPickerTabProvided": "Provided", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Checking availability...", "domainPickerNoMatchingDomains": "No matching domains found. Try a different domain or check the organization's domain settings.", "domainPickerOrganizationDomains": "Organization Domains", "domainPickerProvidedDomains": "Provided Domains", "domainPickerSubdomain": "Subdomain: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Show More", "regionSelectorTitle": "Select Region", "regionSelectorInfo": "Selecting a region helps us provide better performance for your location. You do not have to be in the same region as your server.", "regionSelectorPlaceholder": "Choose a region", "regionSelectorComingSoon": "Coming Soon", "billingLoadingSubscription": "Loading subscription...", "billingFreeTier": "Free Tier", "billingWarningOverLimit": "Warning: You have exceeded one or more usage limits. Your sites will not connect until you modify your subscription or adjust your usage.", "billingUsageLimitsOverview": "Usage Limits Overview", "billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.", "billingDataUsage": "Data Usage", "billingSites": "Sites", "billingUsers": "Users", "billingDomains": "Domains", "billingOrganizations": "Orgs", "billingRemoteExitNodes": "Remote Nodes", "billingNoLimitConfigured": "No limit configured", "billingEstimatedPeriod": "Estimated Billing Period", "billingIncludedUsage": "Included Usage", "billingIncludedUsageDescription": "Usage included with your current subscription plan", "billingFreeTierIncludedUsage": "Free tier usage allowances", "billingIncluded": "included", "billingEstimatedTotal": "Estimated Total:", "billingNotes": "Notes", "billingEstimateNote": "This is an estimate based on your current usage.", "billingActualChargesMayVary": "Actual charges may vary.", "billingBilledAtEnd": "You will be billed at the end of the billing period.", "billingModifySubscription": "Modify Subscription", "billingStartSubscription": "Start Subscription", "billingRecurringCharge": "Recurring Charge", "billingManageSubscriptionSettings": "Manage subscription settings and preferences", "billingNoActiveSubscription": "You don't have an active subscription. Start your subscription to increase usage limits.", "billingFailedToLoadSubscription": "Failed to load subscription", "billingFailedToLoadUsage": "Failed to load usage", "billingFailedToGetCheckoutUrl": "Failed to get checkout URL", "billingPleaseTryAgainLater": "Please try again later.", "billingCheckoutError": "Checkout Error", "billingFailedToGetPortalUrl": "Failed to get portal URL", "billingPortalError": "Portal Error", "billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.", "billingSInfo": "How many sites you can use", "billingUsersInfo": "How many users you can use", "billingDomainInfo": "How many domains you can use", "billingRemoteExitNodesInfo": "How many remote nodes you can use", "billingLicenseKeys": "License Keys", "billingLicenseKeysDescription": "Manage your license key subscriptions", "billingLicenseSubscription": "License Subscription", "billingInactive": "Inactive", "billingLicenseItem": "License Item", "billingQuantity": "Quantity", "billingTotal": "total", "billingModifyLicenses": "Modify License Subscription", "domainNotFound": "Domain Not Found", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", "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", "securityKeyRegister": "Register New Security Key", "securityKeyList": "Your Security Keys", "securityKeyNone": "No security keys registered yet", "securityKeyNameRequired": "Name is required", "securityKeyRemove": "Remove", "securityKeyLastUsed": "Last used: {date}", "securityKeyNameLabel": "Security Key Name", "securityKeyRegisterSuccess": "Security key registered successfully", "securityKeyRegisterError": "Failed to register security key", "securityKeyRemoveSuccess": "Security key removed successfully", "securityKeyRemoveError": "Failed to remove security key", "securityKeyLoadError": "Failed to load security keys", "securityKeyLogin": "Use Security Key", "securityKeyAuthError": "Failed to authenticate with security key", "securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.", "registering": "Registering...", "securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.", "securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.", "securityKeyPermissionDenied": "Please allow access to your security key to continue signing in.", "securityKeyRemovedTooQuickly": "Please keep your security key connected until the sign-in process completes.", "securityKeyNotSupported": "Your security key may not be compatible. Please try a different security key.", "securityKeyUnknownError": "There was a problem using your security key. Please try again.", "twoFactorRequired": "Two-factor authentication is required to register a security key.", "twoFactor": "Two-Factor Authentication", "twoFactorAuthentication": "Two-Factor Authentication", "twoFactorDescription": "This organization requires two-factor authentication.", "enableTwoFactor": "Enable Two-Factor Authentication", "organizationSecurityPolicy": "Organization Security Policy", "organizationSecurityPolicyDescription": "This organization has security requirements that must be met before you can access it", "securityRequirements": "Security Requirements", "allRequirementsMet": "All requirements have been met", "completeRequirementsToContinue": "Complete the requirements below to continue accessing this organization", "youCanNowAccessOrganization": "You can now access this organization", "reauthenticationRequired": "Session Length", "reauthenticationDescription": "This organization requires you to log in every {maxDays} days.", "reauthenticationDescriptionHours": "This organization requires you to log in every {maxHours} hours.", "reauthenticateNow": "Log In Again", "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", "securityKeyAdd": "Add Security Key", "securityKeyRegisterTitle": "Register New Security Key", "securityKeyRegisterDescription": "Connect your security key and enter a name to identify it", "securityKeyTwoFactorRequired": "Two-Factor Authentication Required", "securityKeyTwoFactorDescription": "Please enter your two-factor authentication code to register the security key", "securityKeyTwoFactorRemoveDescription": "Please enter your two-factor authentication code to remove the security key", "securityKeyTwoFactorCode": "Two-Factor Code", "securityKeyRemoveTitle": "Remove Security Key", "securityKeyRemoveDescription": "Enter your password to remove the security key \"{name}\"", "securityKeyNoKeysRegistered": "No security keys registered", "securityKeyNoKeysDescription": "Add a security key to enhance your account security", "createDomainRequired": "Domain is required", "createDomainAddDnsRecords": "Add DNS Records", "createDomainAddDnsRecordsDescription": "Add the following DNS records to your domain provider to complete the setup.", "createDomainNsRecords": "NS Records", "createDomainRecord": "Record", "createDomainType": "Type:", "createDomainName": "Name:", "createDomainValue": "Value:", "createDomainCnameRecords": "CNAME Records", "createDomainARecords": "A Records", "createDomainRecordNumber": "Record {number}", "createDomainTxtRecords": "TXT Records", "createDomainSaveTheseRecords": "Save These Records", "createDomainSaveTheseRecordsDescription": "Make sure to save these DNS records as you will not see them again.", "createDomainDnsPropagation": "DNS Propagation", "createDomainDnsPropagationDescription": "DNS changes may take some time to propagate across the internet. This can take anywhere from a few minutes to 48 hours, depending on your DNS provider and TTL settings.", "resourcePortRequired": "Port number is required for non-HTTP resources", "resourcePortNotAllowed": "Port number should not be set for HTTP resources", "billingPricingCalculatorLink": "Pricing Calculator", "billingYourPlan": "Your Plan", "billingViewOrModifyPlan": "View or modify your current plan", "billingViewPlanDetails": "View Plan Details", "billingUsageAndLimits": "Usage and Limits", "billingViewUsageAndLimits": "View your plan's limits and current usage", "billingCurrentUsage": "Current Usage", "billingMaximumLimits": "Maximum Limits", "billingRemoteNodes": "Remote Nodes", "billingUnlimited": "Unlimited", "billingPaidLicenseKeys": "Paid License Keys", "billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys", "billingCurrentKeys": "Current Keys", "billingModifyCurrentPlan": "Modify Current Plan", "billingConfirmUpgrade": "Confirm Upgrade", "billingConfirmDowngrade": "Confirm Downgrade", "billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.", "billingConfirmDowngradeDescription": "You are about to downgrade your plan. Review the new limits and pricing below.", "billingPlanIncludes": "Plan Includes", "billingProcessing": "Processing...", "billingConfirmUpgradeButton": "Confirm Upgrade", "billingConfirmDowngradeButton": "Confirm Downgrade", "billingLimitViolationWarning": "Usage Exceeds New Plan Limits", "billingLimitViolationDescription": "Your current usage exceeds the limits of this plan. After downgrading, all actions will be disabled until you reduce usage within the new limits. Please review the features below that are currently over the limits. Limits in violation:", "billingFeatureLossWarning": "Feature Availability Notice", "billingFeatureLossDescription": "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available.", "billingUsageExceedsLimit": "Current usage ({current}) exceeds limit ({limit})", "billingPastDueTitle": "Payment Past Due", "billingPastDueDescription": "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier.", "billingUnpaidTitle": "Subscription Unpaid", "billingUnpaidDescription": "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription.", "billingIncompleteTitle": "Payment Incomplete", "billingIncompleteDescription": "Your payment is incomplete. Please complete the payment process to activate your subscription.", "billingIncompleteExpiredTitle": "Payment Expired", "billingIncompleteExpiredDescription": "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features.", "billingManageSubscription": "Manage your subscription", "billingResolvePaymentIssue": "Please resolve your payment issue before upgrading or downgrading", "signUpTerms": { "IAgreeToThe": "I agree to the", "termsOfService": "terms of service", "and": "and", "privacyPolicy": "privacy policy." }, "signUpMarketing": { "keepMeInTheLoop": "Keep me in the loop with news, updates, and new features by email." }, "siteRequired": "Site is required.", "olmTunnel": "Olm Tunnel", "olmTunnelDescription": "Use Olm for client connectivity", "errorCreatingClient": "Error creating client", "clientDefaultsNotFound": "Client defaults not found", "createClient": "Create Client", "createClientDescription": "Create a new client to access private resources", "seeAllClients": "See All Clients", "clientInformation": "Client Information", "clientNamePlaceholder": "Client name", "address": "Address", "subnetPlaceholder": "Subnet", "addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "selectSites": "Select sites", "sitesDescription": "The client will have connectivity to the selected sites", "clientInstallOlm": "Install Machine Client", "clientInstallOlmDescription": "Install the machine client for your system", "clientOlmCredentials": "Credentials", "clientOlmCredentialsDescription": "This is how the client will authenticate with the server", "olmEndpoint": "Endpoint", "olmId": "ID", "olmSecretKey": "Secret", "clientCredentialsSave": "Save the Credentials", "clientCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", "generalSettingsDescription": "Configure the general settings for this client", "clientUpdated": "Client updated", "clientUpdatedDescription": "The client has been updated.", "clientUpdateFailed": "Failed to update client", "clientUpdateError": "An error occurred while updating the client.", "sitesFetchFailed": "Failed to fetch sites", "sitesFetchError": "An error occurred while fetching sites.", "olmErrorFetchReleases": "An error occurred while fetching Olm releases.", "olmErrorFetchLatest": "An error occurred while fetching the latest Olm release.", "enterCidrRange": "Enter CIDR range", "resourceEnableProxy": "Enable Public Proxy", "resourceEnableProxyDescription": "Enable public proxying to this resource. This allows access to the resource from outside the network through the cloud on an open port. Requires Traefik config.", "externalProxyEnabled": "External Proxy Enabled", "addNewTarget": "Add New Target", "targetsList": "Targets List", "advancedMode": "Advanced Mode", "advancedSettings": "Advanced Settings", "targetErrorDuplicateTargetFound": "Duplicate target found", "healthCheckHealthy": "Healthy", "healthCheckUnhealthy": "Unhealthy", "healthCheckUnknown": "Unknown", "healthCheck": "Health Check", "configureHealthCheck": "Configure Health Check", "configureHealthCheckDescription": "Set up health monitoring for {target}", "enableHealthChecks": "Enable Health Checks", "enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.", "healthScheme": "Method", "healthSelectScheme": "Select Method", "healthCheckPortInvalid": "Health check port must be between 1 and 65535", "healthCheckPath": "Path", "healthHostname": "IP / Host", "healthPort": "Port", "healthCheckPathDescription": "The path to check for health status.", "healthyIntervalSeconds": "Healthy Interval (sec)", "unhealthyIntervalSeconds": "Unhealthy Interval (sec)", "IntervalSeconds": "Healthy Interval", "timeoutSeconds": "Timeout (sec)", "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": "Comma separated list of commands the user is allowed to run with sudo.", "sshCreateHomeDir": "Create Home Directory", "sshUnixGroups": "Unix Groups", "sshUnixGroupsDescription": "Comma separated 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.", "customHeaders": "Custom Headers", "customHeadersDescription": "Headers new line separated: Header-Name: value", "headersValidationError": "Headers must be in the format: Header-Name: value", "saveHealthCheck": "Save Health Check", "healthCheckSaved": "Health Check Saved", "healthCheckSavedDescription": "Health check configuration has been saved successfully", "healthCheckError": "Health Check Error", "healthCheckErrorDescription": "An error occurred while saving the health check configuration", "healthCheckPathRequired": "Health check path is required", "healthCheckMethodRequired": "HTTP method is required", "healthCheckIntervalMin": "Check interval must be at least 5 seconds", "healthCheckTimeoutMin": "Timeout must be at least 1 second", "healthCheckRetryMin": "Retry attempts must be at least 1", "httpMethod": "HTTP Method", "selectHttpMethod": "Select HTTP method", "domainPickerSubdomainLabel": "Subdomain", "domainPickerBaseDomainLabel": "Base Domain", "domainPickerSearchDomains": "Search domains...", "domainPickerNoDomainsFound": "No domains found", "domainPickerLoadingDomains": "Loading domains...", "domainPickerSelectBaseDomain": "Select base domain...", "domainPickerNotAvailableForCname": "Not available for CNAME domains", "domainPickerEnterSubdomainOrLeaveBlank": "Enter subdomain or leave blank to use base domain.", "domainPickerEnterSubdomainToSearch": "Enter a subdomain to search and select from available free domains.", "domainPickerFreeDomains": "Free Domains", "domainPickerSearchForAvailableDomains": "Search for available domains", "domainPickerNotWorkSelfHosted": "Note: Free provided domains are not available for self-hosted instances right now.", "resourceDomain": "Domain", "resourceEditDomain": "Edit Domain", "siteName": "Site Name", "proxyPort": "Port", "resourcesTableProxyResources": "Public", "resourcesTableClientResources": "Private", "resourcesTableNoProxyResourcesFound": "No proxy resources found.", "resourcesTableNoInternalResourcesFound": "No internal resources found.", "resourcesTableDestination": "Destination", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Alias Address", "resourcesTableAliasAddressInfo": "This address is part of the organization's utility subnet. It's used to resolve alias records using internal DNS resolution.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.", "resourcesTableNoTargets": "No targets", "resourcesTableHealthy": "Healthy", "resourcesTableDegraded": "Degraded", "resourcesTableOffline": "Offline", "resourcesTableUnknown": "Unknown", "resourcesTableNotMonitored": "Not monitored", "editInternalResourceDialogEditClientResource": "Edit Private Resource", "editInternalResourceDialogUpdateResourceProperties": "Update the resource configuration and access controls for {resourceName}", "editInternalResourceDialogResourceProperties": "Resource Properties", "editInternalResourceDialogName": "Name", "editInternalResourceDialogProtocol": "Protocol", "editInternalResourceDialogSitePort": "Site Port", "editInternalResourceDialogTargetConfiguration": "Target Configuration", "editInternalResourceDialogCancel": "Cancel", "editInternalResourceDialogSaveResource": "Save Resource", "editInternalResourceDialogSuccess": "Success", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Internal resource updated successfully", "editInternalResourceDialogError": "Error", "editInternalResourceDialogFailedToUpdateInternalResource": "Failed to update internal resource", "editInternalResourceDialogNameRequired": "Name is required", "editInternalResourceDialogNameMaxLength": "Name must be less than 255 characters", "editInternalResourceDialogProxyPortMin": "Proxy port must be at least 1", "editInternalResourceDialogProxyPortMax": "Proxy port must be less than 65536", "editInternalResourceDialogInvalidIPAddressFormat": "Invalid IP address format", "editInternalResourceDialogDestinationPortMin": "Destination port must be at least 1", "editInternalResourceDialogDestinationPortMax": "Destination port must be less than 65536", "editInternalResourceDialogPortModeRequired": "Protocol, proxy port, and destination port are required for port mode", "editInternalResourceDialogMode": "Mode", "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Destination", "editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", "editInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "createInternalResourceDialogNoSitesAvailable": "No Sites Available", "createInternalResourceDialogNoSitesAvailableDescription": "You need to have at least one Newt site with a subnet configured to create internal resources.", "createInternalResourceDialogClose": "Close", "createInternalResourceDialogCreateClientResource": "Create Private Resource", "createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization", "createInternalResourceDialogResourceProperties": "Resource Properties", "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Site", "selectSite": "Select site...", "noSitesFound": "No sites found.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Site Port", "createInternalResourceDialogSitePortDescription": "Use this port to access the resource on the site when connected with a client.", "createInternalResourceDialogTargetConfiguration": "Target Configuration", "createInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.", "createInternalResourceDialogDestinationPortDescription": "The port on the destination IP where the resource is accessible.", "createInternalResourceDialogCancel": "Cancel", "createInternalResourceDialogCreateResource": "Create Resource", "createInternalResourceDialogSuccess": "Success", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Internal resource created successfully", "createInternalResourceDialogError": "Error", "createInternalResourceDialogFailedToCreateInternalResource": "Failed to create internal resource", "createInternalResourceDialogNameRequired": "Name is required", "createInternalResourceDialogNameMaxLength": "Name must be less than 255 characters", "createInternalResourceDialogPleaseSelectSite": "Please select a site", "createInternalResourceDialogProxyPortMin": "Proxy port must be at least 1", "createInternalResourceDialogProxyPortMax": "Proxy port must be less than 65536", "createInternalResourceDialogInvalidIPAddressFormat": "Invalid IP address format", "createInternalResourceDialogDestinationPortMin": "Destination port must be at least 1", "createInternalResourceDialogDestinationPortMax": "Destination port must be less than 65536", "createInternalResourceDialogPortModeRequired": "Protocol, proxy port, and destination port are required for port mode", "createInternalResourceDialogMode": "Mode", "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Destination", "createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.", "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.", "siteConfiguration": "Configuration", "siteAcceptClientConnections": "Accept Client Connections", "siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.", "siteAddress": "Site Address (Advanced)", "siteAddressDescription": "The internal address of the site. Must fall within the organization's subnet.", "siteNameDescription": "The display name of the site that can be changed later.", "autoLoginExternalIdp": "Auto Login with External IDP", "autoLoginExternalIdpDescription": "Immediately redirect the user to the external identity provider for authentication.", "selectIdp": "Select IDP", "selectIdpPlaceholder": "Choose an IDP...", "selectIdpRequired": "Please select an IDP when auto login is enabled.", "autoLoginTitle": "Redirecting", "autoLoginDescription": "Redirecting you to the external identity provider for authentication.", "autoLoginProcessing": "Preparing authentication...", "autoLoginRedirecting": "Redirecting to login...", "autoLoginError": "Auto Login Error", "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.", "remoteExitNodeManageRemoteExitNodes": "Remote Nodes", "remoteExitNodeDescription": "Self-host your own remote relay and proxy server nodes", "remoteExitNodes": "Nodes", "searchRemoteExitNodes": "Search nodes...", "remoteExitNodeAdd": "Add Node", "remoteExitNodeErrorDelete": "Error deleting node", "remoteExitNodeQuestionRemove": "Are you sure you want to remove the node from the organization?", "remoteExitNodeMessageRemove": "Once removed, the node will no longer be accessible.", "remoteExitNodeConfirmDelete": "Confirm Delete Node", "remoteExitNodeDelete": "Delete Node", "sidebarRemoteExitNodes": "Remote Nodes", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Secret", "remoteExitNodeCreate": { "title": "Create Remote Node", "description": "Create a new self-hosted remote relay and proxy server node", "viewAllButton": "View All Nodes", "strategy": { "title": "Creation Strategy", "description": "Select how you want to create the remote node", "adopt": { "title": "Adopt Node", "description": "Choose this if you already have the credentials for the node." }, "generate": { "title": "Generate Keys", "description": "Choose this if you want to generate new keys for the node." } }, "adopt": { "title": "Adopt Existing Node", "description": "Enter the credentials of the existing node you want to adopt", "nodeIdLabel": "Node ID", "nodeIdDescription": "The ID of the existing node you want to adopt", "secretLabel": "Secret", "secretDescription": "The secret key of the existing node", "submitButton": "Adopt Node" }, "generate": { "title": "Generated Credentials", "description": "Use these generated credentials to configure the node", "nodeIdTitle": "Node ID", "secretTitle": "Secret", "saveCredentialsTitle": "Add Credentials to Config", "saveCredentialsDescription": "Add these credentials to your self-hosted Pangolin node configuration file to complete the connection.", "submitButton": "Create Node" }, "validation": { "adoptRequired": "Node ID and Secret are required when adopting an existing node" }, "errors": { "loadDefaultsFailed": "Failed to load defaults", "defaultsNotLoaded": "Defaults not loaded", "createFailed": "Failed to create node" }, "success": { "created": "Node created successfully" } }, "remoteExitNodeSelection": "Node Selection", "remoteExitNodeSelectionDescription": "Select a node to route traffic through for this local site", "remoteExitNodeRequired": "A node must be selected for local sites", "noRemoteExitNodesAvailable": "No Nodes Available", "noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.", "exitNode": "Exit Node", "country": "Country", "rulesMatchCountry": "Currently based on source IP", "managedSelfHosted": { "title": "Managed Self-Hosted", "description": "More reliable and low-maintenance self-hosted Pangolin server with extra bells and whistles", "introTitle": "Managed Self-Hosted Pangolin", "introDescription": "is a deployment option designed for people who want simplicity and extra reliability while still keeping their data private and self-hosted.", "introDetail": "With this option, you still run your own Pangolin node — your tunnels, SSL termination, and traffic all stay on your server. The difference is that management and monitoring are handled through our cloud dashboard, which unlocks a number of benefits:", "benefitSimplerOperations": { "title": "Simpler operations", "description": "No need to run your own mail server or set up complex alerting. You'll get health checks and downtime alerts out of the box." }, "benefitAutomaticUpdates": { "title": "Automatic updates", "description": "The cloud dashboard evolves quickly, so you get new features and bug fixes without having to manually pull new containers every time." }, "benefitLessMaintenance": { "title": "Less maintenance", "description": "No database migrations, backups, or extra infrastructure to manage. We handle that in the cloud." }, "benefitCloudFailover": { "title": "Cloud failover", "description": "If your node goes down, your tunnels can temporarily fail over to our cloud points of presence until you bring it back online." }, "benefitHighAvailability": { "title": "High availability (PoPs)", "description": "You can also attach multiple nodes to your account for redundancy and better performance." }, "benefitFutureEnhancements": { "title": "Future enhancements", "description": "We're planning to add more analytics, alerting, and management tools to make your deployment even more robust." }, "docsAlert": { "text": "Learn more about the Managed Self-Hosted option in our", "documentation": "documentation" }, "convertButton": "Convert This Node to Managed Self-Hosted" }, "internationaldomaindetected": "International Domain Detected", "willbestoredas": "Will be stored as:", "roleMappingDescription": "Determine how roles are assigned to users when they sign in when Auto Provision is enabled.", "selectRole": "Select a Role", "roleMappingExpression": "Expression", "selectRolePlaceholder": "Choose a role", "selectRoleDescription": "Select a role to assign to all users from this identity provider", "roleMappingExpressionDescription": "Enter a JMESPath expression to extract role information from the ID token", "idpTenantIdRequired": "Tenant ID is required", "invalidValue": "Invalid value", "idpTypeLabel": "Identity Provider Type", "roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'", "idpGoogleConfiguration": "Google Configuration", "idpGoogleConfigurationDescription": "Configure the Google OAuth2 credentials", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Google OAuth2 Client Secret", "idpAzureConfiguration": "Azure Entra ID Configuration", "idpAzureConfigurationDescription": "Configure Azure Entra ID OAuth2 credentials", "idpTenantId": "Tenant ID", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "Azure tenant ID (found in Azure Active Directory overview)", "idpAzureClientIdDescription": "Azure App Registration Client ID", "idpAzureClientSecretDescription": "Azure App Registration Client Secret", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Google Configuration", "idpAzureConfigurationTitle": "Azure Entra ID Configuration", "idpTenantIdLabel": "Tenant ID", "idpAzureClientIdDescription2": "Azure App Registration Client ID", "idpAzureClientSecretDescription2": "Azure App Registration Client Secret", "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnet", "subnetDescription": "The subnet for this organization's network configuration.", "customDomain": "Custom Domain", "authPage": "Authentication Pages", "authPageDescription": "Set a custom domain for the organization's authentication pages", "authPageDomain": "Auth Page Domain", "authPageBranding": "Custom Branding", "authPageBrandingDescription": "Configure the branding that appears on authentication pages for this organization", "authPageBrandingUpdated": "Auth page Branding updated successfully", "authPageBrandingRemoved": "Auth page Branding removed successfully", "authPageBrandingRemoveTitle": "Remove Auth Page Branding", "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)", "brandingOrgTitle": "Title for Organization Auth Page", "brandingOrgDescription": "{orgName} will be replaced with the organization's name", "brandingOrgSubtitle": "Subtitle for Organization Auth Page", "brandingResourceTitle": "Title for Resource Auth Page", "brandingResourceSubtitle": "Subtitle for Resource Auth Page", "brandingResourceDescription": "{resourceName} will be replaced with the organization's name", "saveAuthPageDomain": "Save Domain", "saveAuthPageBranding": "Save Branding", "removeAuthPageBranding": "Remove Branding", "noDomainSet": "No domain set", "changeDomain": "Change Domain", "selectDomain": "Select Domain", "restartCertificate": "Restart Certificate", "editAuthPageDomain": "Edit Auth Page Domain", "setAuthPageDomain": "Set Auth Page Domain", "failedToFetchCertificate": "Failed to fetch certificate", "failedToRestartCertificate": "Failed to restart certificate", "addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.", "selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page", "domainPickerProvidedDomain": "Provided Domain", "domainPickerFreeProvidedDomain": "Free Provided Domain", "domainPickerVerified": "Verified", "domainPickerUnverified": "Unverified", "domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.", "domainPickerError": "Error", "domainPickerErrorLoadDomains": "Failed to load organization domains", "domainPickerErrorCheckAvailability": "Failed to check domain availability", "domainPickerInvalidSubdomain": "Invalid subdomain", "domainPickerInvalidSubdomainRemoved": "The input \"{sub}\" was removed because it's not valid.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.", "domainPickerSubdomainSanitized": "Subdomain sanitized", "domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"", "orgAuthSignInTitle": "Organization Sign In", "orgAuthChooseIdpDescription": "Choose your identity provider to continue", "orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.", "orgAuthSignInWithPangolin": "Sign in with Pangolin", "orgAuthSignInToOrg": "Sign in to an organization", "orgAuthSelectOrgTitle": "Organization Sign In", "orgAuthSelectOrgDescription": "Enter your organization ID to continue", "orgAuthOrgIdPlaceholder": "your-organization", "orgAuthOrgIdHelp": "Enter your organization's unique identifier", "orgAuthSelectOrgHelp": "After entering your organization ID, you'll be taken to your organization's sign-in page where you can use SSO or your organization credentials.", "orgAuthRememberOrgId": "Remember this organization ID", "orgAuthBackToSignIn": "Back to standard sign in", "orgAuthNoAccount": "Don't have an account?", "subscriptionRequiredToUse": "A subscription is required to use this feature.", "mustUpgradeToUse": "You must upgrade your subscription to use this feature.", "subscriptionRequiredTierToUse": "This feature requires {tier}.", "upgradeToTierToUse": "Upgrade to {tier} to use this feature.", "subscriptionTierTier1": "Home", "subscriptionTierTier2": "Team", "subscriptionTierTier3": "Business", "subscriptionTierEnterprise": "Enterprise", "idpDisabled": "Identity providers are disabled.", "orgAuthPageDisabled": "Organization auth page is disabled.", "domainRestartedDescription": "Domain verification restarted successfully", "resourceAddEntrypointsEditFile": "Edit file: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Edit file: docker-compose.yml", "emailVerificationRequired": "Email verification is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", "twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.", "additionalSecurityRequired": "Additional Security Required", "organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.", "completeTheseSteps": "Complete these steps", "enableTwoFactorAuthentication": "Enable two-factor authentication", "completeSecuritySteps": "Complete Security Steps", "securitySettings": "Security Settings", "dangerSection": "Danger Zone", "dangerSectionDescription": "Permanently delete all data associated with this organization", "securitySettingsDescription": "Configure security policies for the organization", "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", "requireTwoFactorDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", "requireTwoFactorCannotEnableDescription": "You must enable two-factor authentication for your account before enforcing it for all users", "maxSessionLength": "Maximum Session Length", "maxSessionLengthDescription": "Set the maximum duration for user sessions. After this time, users will need to re-authenticate.", "maxSessionLengthDisabledDescription": "This feature requires a valid license (Enterprise) or active subscription (SaaS)", "selectSessionLength": "Select session length", "unenforced": "Unenforced", "1Hour": "1 hour", "3Hours": "3 hours", "6Hours": "6 hours", "12Hours": "12 hours", "1DaySession": "1 day", "3Days": "3 days", "7Days": "7 days", "14Days": "14 days", "30DaysSession": "30 days", "90DaysSession": "90 days", "180DaysSession": "180 days", "passwordExpiryDays": "Password Expiry", "editPasswordExpiryDescription": "Set the number of days before users are required to change their password.", "selectPasswordExpiry": "Select password expiry", "30Days": "30 days", "1Day": "1 day", "60Days": "60 days", "90Days": "90 days", "180Days": "180 days", "1Year": "1 year", "subscriptionBadge": "Subscription Required", "securityPolicyChangeWarning": "Security Policy Change Warning", "securityPolicyChangeDescription": "You are about to change security policy settings. After saving, you may need to reauthenticate to comply with these policy updates. All users who are not compliant will also need to reauthenticate.", "securityPolicyChangeConfirmMessage": "I confirm", "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "An error occurred while updating the auth page settings", "authPageErrorUpdate": "Unable to update auth page", "authPageDomainUpdated": "Auth page Domain updated successfully", "healthCheckNotAvailable": "Local", "rewritePath": "Rewrite Path", "rewritePathDescription": "Optionally rewrite the path before forwarding to the target.", "continueToApplication": "Continue to application", "checkingInvite": "Checking Invite", "setResourceHeaderAuth": "setResourceHeaderAuth", "resourceHeaderAuthRemove": "Remove Header Auth", "resourceHeaderAuthRemoveDescription": "Header authentication removed successfully.", "resourceErrorHeaderAuthRemove": "Failed to remove Header Authentication", "resourceErrorHeaderAuthRemoveDescription": "Could not remove header authentication for the resource.", "resourceHeaderAuthProtectionEnabled": "Header Authentication Enabled", "resourceHeaderAuthProtectionDisabled": "Header Authentication Disabled", "headerAuthRemove": "Remove Header Auth", "headerAuthAdd": "Add Header Auth", "resourceErrorHeaderAuthSetup": "Failed to set Header Authentication", "resourceErrorHeaderAuthSetupDescription": "Could not set header authentication for the resource.", "resourceHeaderAuthSetup": "Header Authentication set successfully", "resourceHeaderAuthSetupDescription": "Header authentication has been successfully set.", "resourceHeaderAuthSetupTitle": "Set Header Authentication", "resourceHeaderAuthSetupTitleDescription": "Set the basic auth credentials (username and password) to protect this resource with HTTP Header Authentication. Access it using the format https://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Set Header Authentication", "actionSetResourceHeaderAuth": "Set Header Authentication", "enterpriseEdition": "Enterprise Edition", "unlicensed": "Unlicensed", "beta": "Beta", "manageUserDevices": "User Devices", "manageUserDevicesDescription": "View and manage devices that users use to privately connect to resources", "downloadClientBannerTitle": "Download Pangolin Client", "downloadClientBannerDescription": "Download the Pangolin client for your system to connect to the Pangolin network and access resources privately.", "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 be deployed as a CLI or a container.", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Container", "clientsTableUserClients": "User", "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Valid Until", "saasLicenseKeysSettingsTitle": "Enterprise Licenses", "saasLicenseKeysSettingsDescription": "Generate and manage Enterprise license keys for self-hosted Pangolin instances", "sidebarEnterpriseLicenses": "Licenses", "generateLicenseKey": "Generate License Key", "generateLicenseKeyForm": { "validation": { "emailRequired": "Please enter a valid email address", "useCaseTypeRequired": "Please select a use case type", "firstNameRequired": "First name is required", "lastNameRequired": "Last name is required", "primaryUseRequired": "Please describe your primary use", "jobTitleRequiredBusiness": "Job title is required for business use", "industryRequiredBusiness": "Industry is required for business use", "stateProvinceRegionRequired": "State/Province/Region is required", "postalZipCodeRequired": "Postal/ZIP Code is required", "companyNameRequiredBusiness": "Company name is required for business use", "countryOfResidenceRequiredBusiness": "Country of residence is required for business use", "countryRequiredPersonal": "Country is required for personal use", "agreeToTermsRequired": "You must agree to the terms", "complianceConfirmationRequired": "You must confirm compliance with the Fossorial Commercial License" }, "useCaseOptions": { "personal": { "title": "Personal Use", "description": "For individual, non-commercial use such as learning, personal projects, or experimentation." }, "business": { "title": "Business Use", "description": "For use within organizations, companies, or commercial or revenue-generating activities." } }, "steps": { "emailLicenseType": { "title": "Email & License Type", "description": "Enter your email and choose your license type" }, "personalInformation": { "title": "Personal Information", "description": "Tell us about yourself" }, "contactInformation": { "title": "Contact Information", "description": "Your contact details" }, "termsGenerate": { "title": "Terms & Generate", "description": "Review and accept terms to generate your license" } }, "alerts": { "commercialUseDisclosure": { "title": "Usage Disclosure", "description": "Select the license tier that accurately reflects your intended use. The Personal License permits free use of the Software for individual, non-commercial or small-scale commercial activities with annual gross revenue under $100,000 USD. Any use beyond these limits — including use within a business, organization, or other revenue-generating environment — requires a valid Enterprise License and payment of the applicable licensing fee. All users, whether Personal or Enterprise, must comply with the Fossorial Commercial License Terms." }, "trialPeriodInformation": { "title": "Trial Period Information", "description": "This License Key enables Enterprise features for a 7-day evaluation period. Continued access to Paid Features beyond the evaluation period requires activation under a valid Personal or Enterprise License. For Enterprise licensing, contact sales@pangolin.net." } }, "form": { "useCaseQuestion": "Are you using Pangolin for personal or business use?", "firstName": "First Name", "lastName": "Last Name", "jobTitle": "Job Title", "primaryUseQuestion": "What do you primarily plan to use Pangolin for?", "industryQuestion": "What is your industry?", "prospectiveUsersQuestion": "How many prospective users do you expect to have?", "prospectiveSitesQuestion": "How many prospective sites (tunnels) do you expect to have?", "companyName": "Company name", "countryOfResidence": "Country of residence", "stateProvinceRegion": "State / Province / Region", "postalZipCode": "Postal / ZIP Code", "companyWebsite": "Company website", "companyPhoneNumber": "Company phone number", "country": "Country", "phoneNumberOptional": "Phone number (optional)", "complianceConfirmation": "I confirm that the information I provided is accurate and that I am in compliance with the Fossorial Commercial License. Reporting inaccurate information or misidentifying use of the product is a violation of the license and may result in your key getting revoked." }, "buttons": { "close": "Close", "previous": "Previous", "next": "Next", "generateLicenseKey": "Generate License Key" }, "toasts": { "success": { "title": "License key generated successfully", "description": "Your license key has been generated and is ready to use." }, "error": { "title": "Failed to generate license key", "description": "An error occurred while generating the license key." } } }, "newPricingLicenseForm": { "title": "Get a license", "description": "Choose a plan and tell us how you plan to use Pangolin.", "chooseTier": "Choose your plan", "viewPricingLink": "See pricing, features, and limits", "tiers": { "starter": { "title": "Starter", "description": "Enterprise features, 25 users, 25 sites, and community support." }, "scale": { "title": "Scale", "description": "Enterprise features, 50 users, 50 sites, and priority support." } }, "personalUseOnly": "Personal use only (free license — no checkout)", "buttons": { "continueToCheckout": "Continue to Checkout" }, "toasts": { "checkoutError": { "title": "Checkout error", "description": "Could not start checkout. Please try again." } } }, "priority": "Priority", "priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.", "instanceName": "Instance Name", "pathMatchModalTitle": "Configure Path Matching", "pathMatchModalDescription": "Set up how incoming requests should be matched based on their path.", "pathMatchType": "Match Type", "pathMatchPrefix": "Prefix", "pathMatchExact": "Exact", "pathMatchRegex": "Regex", "pathMatchValue": "Path Value", "clear": "Clear", "saveChanges": "Save Changes", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/path", "pathMatchPrefixHelp": "Example: /api matches /api, /api/users, etc.", "pathMatchExactHelp": "Example: /api matches only /api", "pathMatchRegexHelp": "Example: ^/api/.* matches /api/anything", "pathRewriteModalTitle": "Configure Path Rewriting", "pathRewriteModalDescription": "Transform the matched path before forwarding to the target.", "pathRewriteType": "Rewrite Type", "pathRewritePrefixOption": "Prefix - Replace prefix", "pathRewriteExactOption": "Exact - Replace entire path", "pathRewriteRegexOption": "Regex - Pattern replacement", "pathRewriteStripPrefixOption": "Strip Prefix - Remove prefix", "pathRewriteValue": "Rewrite Value", "pathRewriteRegexPlaceholder": "/new/$1", "pathRewriteDefaultPlaceholder": "/new-path", "pathRewritePrefixHelp": "Replace the matched prefix with this value", "pathRewriteExactHelp": "Replace the entire path with this value when the path matches exactly", "pathRewriteRegexHelp": "Use capture groups like $1, $2 for replacement", "pathRewriteStripPrefixHelp": "Leave empty to strip prefix or provide new prefix", "pathRewritePrefix": "Prefix", "pathRewriteExact": "Exact", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Strip", "pathRewriteStripLabel": "strip", "sidebarEnableEnterpriseLicense": "Enable Enterprise License", "cannotbeUndone": "This can not be undone.", "toConfirm": "to confirm.", "deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?", "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.", "sidebarLogs": "Logs", "request": "Request", "requests": "Requests", "logs": "Logs", "logsSettingsDescription": "Monitor logs collected from this organization", "searchLogs": "Search logs...", "action": "Action", "actor": "Actor", "timestamp": "Timestamp", "accessLogs": "Access Logs", "exportCsv": "Export CSV", "exportError": "Unknown error when exporting CSV", "exportCsvTooltip": "Within Time Range", "actorId": "Actor ID", "allowedByRule": "Allowed by Rule", "allowedNoAuth": "Allowed No Auth", "validAccessToken": "Valid Access Token", "validHeaderAuth": "Valid header auth", "validPincode": "Valid Pincode", "validPassword": "Valid Password", "validEmail": "Valid email", "validSSO": "Valid SSO", "resourceBlocked": "Resource Blocked", "droppedByRule": "Dropped by Rule", "noSessions": "No Sessions", "temporaryRequestToken": "Temporary Request Token", "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Reason", "requestLogs": "Request Logs", "requestAnalytics": "Request Analytics", "host": "Host", "location": "Location", "actionLogs": "Action Logs", "sidebarLogsRequest": "Request Logs", "sidebarLogsAccess": "Access Logs", "sidebarLogsAction": "Action Logs", "logRetention": "Log Retention", "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", "requestLogsDescription": "View detailed request logs for resources in this organization", "requestAnalyticsDescription": "View detailed request analytics for resources in this organization", "logRetentionRequestLabel": "Request Log Retention", "logRetentionRequestDescription": "How long to retain request logs", "logRetentionAccessLabel": "Access Log Retention", "logRetentionAccessDescription": "How long to retain access logs", "logRetentionActionLabel": "Action Log Retention", "logRetentionActionDescription": "How long to retain action logs", "logRetentionDisabled": "Disabled", "logRetention3Days": "3 days", "logRetention7Days": "7 days", "logRetention14Days": "14 days", "logRetention30Days": "30 days", "logRetention90Days": "90 days", "logRetentionForever": "Forever", "logRetentionEndOfFollowingYear": "End of following year", "actionLogsDescription": "View a history of actions performed in this organization", "accessLogsDescription": "View access auth requests for resources in this organization", "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a demo or POC trial.", "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a demo or POC trial.", "certResolver": "Certificate Resolver", "certResolverDescription": "Select the certificate resolver to use for this resource.", "selectCertResolver": "Select Certificate Resolver", "enterCustomResolver": "Enter Custom Resolver", "preferWildcardCert": "Prefer Wildcard Certificate", "unverified": "Unverified", "domainSetting": "Domain Settings", "domainSettingDescription": "Configure settings for the domain", "preferWildcardCertDescription": "Attempt to generate a wildcard certificate (requires a properly configured certificate resolver).", "recordName": "Record Name", "auto": "Auto", "TTL": "TTL", "howToAddRecords": "How to Add Records", "dnsRecord": "DNS Records", "required": "Required", "domainSettingsUpdated": "Domain settings updated successfully", "orgOrDomainIdMissing": "Organization or Domain ID is missing", "loadingDNSRecords": "Loading DNS records...", "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Proxy Protocol Settings", "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.", "enableProxyProtocol": "Enable Proxy Protocol", "proxyProtocolInfo": "Preserve client IP addresses for TCP backends", "proxyProtocolVersion": "Proxy Protocol Version", "version1": " Version 1 (Recommended)", "version2": "Version 2", "versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible. Make sure servers transport is added to dynamic config.", "warning": "Warning", "proxyProtocolWarning": "The backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections so only enable this if you know what you're doing. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.", "restarting": "Restarting...", "manual": "Manual", "messageSupport": "Message Support", "supportNotAvailableTitle": "Support Not Available", "supportNotAvailableDescription": "Support is not available right now. You can send an email to support@pangolin.net.", "supportRequestSentTitle": "Support Request Sent", "supportRequestSentDescription": "Your message has been sent successfully.", "supportRequestFailedTitle": "Failed to Send Request", "supportRequestFailedDescription": "An error occurred while sending your support request.", "supportSubjectRequired": "Subject is required", "supportSubjectMaxLength": "Subject must be 255 characters or less", "supportMessageRequired": "Message is required", "supportReplyTo": "Reply To", "supportSubject": "Subject", "supportSubjectPlaceholder": "Enter subject", "supportMessage": "Message", "supportMessagePlaceholder": "Enter your message", "supportSending": "Sending...", "supportSend": "Send", "supportMessageSent": "Message Sent!", "supportWillContact": "We'll be in touch shortly!", "selectLogRetention": "Select log retention", "terms": "Terms", "privacy": "Privacy", "security": "Security", "docs": "Docs", "deviceActivation": "Device activation", "deviceCodeInvalidFormat": "Code must be 9 characters (e.g., A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Invalid or expired code", "deviceCodeVerifyFailed": "Failed to verify device code", "deviceCodeValidating": "Validating device code...", "deviceCodeVerifying": "Verifying device authorization...", "signedInAs": "Signed in as", "deviceCodeEnterPrompt": "Enter the code displayed on the device", "continue": "Continue", "deviceUnknownLocation": "Unknown location", "deviceAuthorizationRequested": "This authorization was requested from {location} on {date}. Make sure you trust this device as it will get access to the account.", "deviceLabel": "Device: {deviceName}", "deviceWantsAccess": "wants to access your account", "deviceExistingAccess": "Existing access:", "deviceFullAccess": "Full access to your account", "deviceOrganizationsAccess": "Access to all organizations your account has access to", "deviceAuthorize": "Authorize {applicationName}", "deviceConnected": "Device Connected!", "deviceAuthorizedMessage": "Device is authorized to access your account. Please return to the client application.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "View Devices", "viewDevicesDescription": "Manage your connected devices", "noDevices": "No devices found", "dateCreated": "Date Created", "unnamedDevice": "Unnamed Device", "deviceQuestionRemove": "Are you sure you want to delete this device?", "deviceMessageRemove": "This action cannot be undone.", "deviceDeleteConfirm": "Delete Device", "deleteDevice": "Delete Device", "errorLoadingDevices": "Error loading devices", "failedToLoadDevices": "Failed to load devices", "deviceDeleted": "Device deleted", "deviceDeletedDescription": "The device has been successfully deleted.", "errorDeletingDevice": "Error deleting device", "failedToDeleteDevice": "Failed to delete device", "showColumns": "Show Columns", "hideColumns": "Hide Columns", "columnVisibility": "Column Visibility", "toggleColumn": "Toggle {columnName} column", "allColumns": "All Columns", "defaultColumns": "Default Columns", "customizeView": "Customize View", "viewOptions": "View Options", "selectAll": "Select All", "selectNone": "Select None", "selectedResources": "Selected Resources", "enableSelected": "Enable Selected", "disableSelected": "Disable Selected", "checkSelectedStatus": "Check Status of Selected", "clients": "Clients", "accessClientSelect": "Select machine clients", "resourceClientDescription": "Machine clients that can access this resource", "regenerate": "Regenerate", "credentials": "Credentials", "savecredentials": "Save Credentials", "regenerateCredentialsButton": "Regenerate Credentials", "regenerateCredentials": "Regenerate Credentials", "generatedcredentials": "Generated Credentials", "copyandsavethesecredentials": "Copy and save these credentials", "copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now.", "credentialsSaved": "Credentials Saved", "credentialsSavedDescription": "Credentials have been regenerated and saved successfully.", "credentialsSaveError": "Credentials Save Error", "credentialsSaveErrorDescription": "An error occurred while regenerating and saving the credentials.", "regenerateCredentialsWarning": "Regenerating credentials will invalidate the previous ones and cause a disconnection. Make sure to update any configurations that use these credentials.", "confirm": "Confirm", "regenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials?", "endpoint": "Endpoint", "Id": "Id", "SecretKey": "Secret Key", "niceId": "Nice ID", "niceIdUpdated": "Nice ID Updated", "niceIdUpdatedSuccessfully": "Nice ID Updated Successfully", "niceIdUpdateError": "Error updating Nice ID", "niceIdUpdateErrorDescription": "An error occurred while updating the Nice ID.", "niceIdCannotBeEmpty": "Nice ID cannot be empty", "enterIdentifier": "Enter identifier", "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Not you? Use a different account.", "deviceLoginDeviceRequestingAccessToAccount": "A device is requesting access to this account.", "loginSelectAuthenticationMethod": "Select an authentication method to continue.", "noData": "No Data", "machineClients": "Machine Clients", "install": "Install", "run": "Run", "clientNameDescription": "The display name of the client that can be changed later.", "clientAddress": "Client Address (Advanced)", "setupFailedToFetchSubnet": "Failed to fetch default subnet", "setupSubnetAdvanced": "Subnet (Advanced)", "setupSubnetDescription": "The subnet for this organization's internal network.", "setupUtilitySubnet": "Utility Subnet (Advanced)", "setupUtilitySubnetDescription": "The subnet for this organization's alias addresses and DNS server.", "siteRegenerateAndDisconnect": "Regenerate and Disconnect", "siteRegenerateAndDisconnectConfirmation": "Are you sure you want to regenerate the credentials and disconnect this site?", "siteRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the site. The site will need to be restarted with the new credentials.", "siteRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this site?", "siteRegenerateCredentialsWarning": "This will regenerate the credentials. The site will stay connected until you manually restart it and use the new credentials.", "clientRegenerateAndDisconnect": "Regenerate and Disconnect", "clientRegenerateAndDisconnectConfirmation": "Are you sure you want to regenerate the credentials and disconnect this client?", "clientRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the client. The client will need to be restarted with the new credentials.", "clientRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this client?", "clientRegenerateCredentialsWarning": "This will regenerate the credentials. The client will stay connected until you manually restart it and use the new credentials.", "remoteExitNodeRegenerateAndDisconnect": "Regenerate and Disconnect", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Are you sure you want to regenerate the credentials and disconnect this remote exit node?", "remoteExitNodeRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the remote exit node. The remote exit node will need to be restarted with the new credentials.", "remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?", "remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.", "agent": "Agent", "personalUseOnly": "Personal Use Only", "loginPageLicenseWatermark": "This instance is licensed for personal use only.", "instanceIsUnlicensed": "This instance is unlicensed.", "portRestrictions": "Port Restrictions", "allPorts": "All", "custom": "Custom", "allPortsAllowed": "All Ports Allowed", "allPortsBlocked": "All Ports Blocked", "tcpPortsDescription": "Specify which TCP ports are allowed for this resource. Use '*' for all ports, leave empty to block all, or enter a comma-separated list of ports and ranges (e.g., 80,443,8000-9000).", "udpPortsDescription": "Specify which UDP ports are allowed for this resource. Use '*' for all ports, leave empty to block all, or enter a comma-separated list of ports and ranges (e.g., 53,123,500-600).", "organizationLoginPageTitle": "Organization Login Page", "organizationLoginPageDescription": "Customize the login page for this organization", "resourceLoginPageTitle": "Resource Login Page", "resourceLoginPageDescription": "Customize the login page for individual resources", "enterConfirmation": "Enter confirmation", "blueprintViewDetails": "Details", "defaultIdentityProvider": "Default Identity Provider", "defaultIdentityProviderDescription": "When a default identity provider is selected, the user will be automatically redirected to the provider for authentication.", "editInternalResourceDialogNetworkSettings": "Network Settings", "editInternalResourceDialogAccessPolicy": "Access Policy", "editInternalResourceDialogAddRoles": "Add Roles", "editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Access Control", "editInternalResourceDialogAccessControlDescription": "Control which roles, users, and machine clients have access to this resource when connected. Admins always have access.", "editInternalResourceDialogPortRangeValidationError": "Port range must be \"*\" for all ports, or a comma-separated list of ports and ranges (e.g., \"80,443,8000-9000\"). Ports must be between 1 and 65535.", "internalResourceAuthDaemonStrategy": "SSH Auth Daemon Location", "internalResourceAuthDaemonStrategyDescription": "Choose where the SSH authentication daemon runs: on the site (Newt) or on a remote host.", "internalResourceAuthDaemonDescription": "The SSH authentication daemon handles SSH key signing and PAM authentication for this resource. Choose whether it runs on the site (Newt) or on a separate remote host. See the documentation for more.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Select Strategy", "internalResourceAuthDaemonStrategyLabel": "Location", "internalResourceAuthDaemonSite": "On Site", "internalResourceAuthDaemonSiteDescription": "Auth daemon runs on the site (Newt).", "internalResourceAuthDaemonRemote": "Remote Host", "internalResourceAuthDaemonRemoteDescription": "Auth daemon runs on this resource's destination - not the site.", "internalResourceAuthDaemonPort": "Daemon Port (optional)", "orgAuthWhatsThis": "Where can I find my organization ID?", "learnMore": "Learn more", "backToHome": "Go back to home", "needToSignInToOrg": "Need to use your organization's identity provider?", "maintenanceMode": "Maintenance Mode", "maintenanceModeDescription": "Display a maintenance page to visitors", "maintenanceModeType": "Maintenance Mode Type", "showMaintenancePage": "Show a maintenance page to visitors", "enableMaintenanceMode": "Enable Maintenance Mode", "automatic": "Automatic", "automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.", "forced": "Forced", "forcedModeDescription": "Always show the maintenance page regardless of backend health. Use this for planned maintenance when you want to prevent all access.", "warning:" : "Warning:", "forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.", "pageTitle": "Page Title", "pageTitleDescription": "The main heading displayed on the maintenance page", "maintenancePageMessage": "Maintenance Message", "maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.", "maintenancePageMessageDescription": "Detailed message explaining the maintenance", "maintenancePageTimeTitle": "Estimated Completion Time (Optional)", "maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM", "maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed", "editDomain": "Edit Domain", "editDomainDescription": "Select a domain for your resource", "maintenanceModeDisabledTooltip": "This feature requires a valid license to enable.", "maintenanceScreenTitle": "Service Temporarily Unavailable", "maintenanceScreenMessage": "We are currently experiencing technical difficulties. Please check back soon.", "maintenanceScreenEstimatedCompletion": "Estimated Completion:", "createInternalResourceDialogDestinationRequired": "Destination is required", "available": "Available", "archived": "Archived", "noArchivedDevices": "No archived devices found", "deviceArchived": "Device archived", "deviceArchivedDescription": "The device has been successfully archived.", "errorArchivingDevice": "Error archiving device", "failedToArchiveDevice": "Failed to archive device", "deviceQuestionArchive": "Are you sure you want to archive this device?", "deviceMessageArchive": "The device will be archived and removed from your active devices list.", "deviceArchiveConfirm": "Archive Device", "archiveDevice": "Archive Device", "archive": "Archive", "deviceUnarchived": "Device unarchived", "deviceUnarchivedDescription": "The device has been successfully unarchived.", "errorUnarchivingDevice": "Error unarchiving device", "failedToUnarchiveDevice": "Failed to unarchive device", "unarchive": "Unarchive", "archiveClient": "Archive Client", "archiveClientQuestion": "Are you sure you want to archive this client?", "archiveClientMessage": "The client will be archived and removed from your active clients list.", "archiveClientConfirm": "Archive Client", "blockClient": "Block Client", "blockClientQuestion": "Are you sure you want to block this client?", "blockClientMessage": "The device will be forced to disconnect if currently connected. You can unblock the device later.", "blockClientConfirm": "Block Client", "active": "Active", "usernameOrEmail": "Username or Email", "selectYourOrganization": "Select your organization", "signInTo": "Log in in to", "signInWithPassword": "Continue with Password", "noAuthMethodsAvailable": "No authentication methods available for this organization.", "enterPassword": "Enter your password", "enterMfaCode": "Enter the code from your authenticator app", "securityKeyRequired": "Please use your security key to sign in.", "needToUseAnotherAccount": "Need to use a different account?", "loginLegalDisclaimer": "By clicking the buttons below, you acknowledge you have read, understand, and agree to the Terms of Service and Privacy Policy.", "termsOfService": "Terms of Service", "privacyPolicy": "Privacy Policy", "userNotFoundWithUsername": "No user found with that username.", "verify": "Verify", "signIn": "Sign In", "forgotPassword": "Forgot password?", "orgSignInTip": "If you've logged in before, you can enter your username or email above to authenticate with your organization's identity provider instead. It's easier!", "continueAnyway": "Continue anyway", "dontShowAgain": "Don't show again", "orgSignInNotice": "Did you know?", "signupOrgNotice": "Trying to sign in?", "signupOrgTip": "Are you trying to sign in through your organization's identity provider?", "signupOrgLink": "Sign in or sign up with your organization instead", "verifyEmailLogInWithDifferentAccount": "Use a Different Account", "logIn": "Log In", "deviceInformation": "Device Information", "deviceInformationDescription": "Information about the device and agent", "deviceSecurity": "Device Security", "deviceSecurityDescription": "Device security posture information", "platform": "Platform", "macosVersion": "macOS Version", "windowsVersion": "Windows Version", "iosVersion": "iOS Version", "androidVersion": "Android Version", "osVersion": "OS Version", "kernelVersion": "Kernel Version", "deviceModel": "Device Model", "serialNumber": "Serial Number", "hostname": "Hostname", "firstSeen": "First Seen", "lastSeen": "Last Seen", "biometricsEnabled": "Biometrics Enabled", "diskEncrypted": "Disk Encrypted", "firewallEnabled": "Firewall Enabled", "autoUpdatesEnabled": "Auto Updates Enabled", "tpmAvailable": "TPM Available", "windowsAntivirusEnabled": "Antivirus Enabled", "macosSipEnabled": "System Integrity Protection (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Firewall Stealth Mode", "linuxAppArmorEnabled": "AppArmor", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "View device information and settings", "devicePendingApprovalDescription": "This device is waiting for approval", "deviceBlockedDescription": "This device is currently blocked. It won't be able to connect to any resources unless unblocked.", "unblockClient": "Unblock Client", "unblockClientDescription": "The device has been unblocked", "unarchiveClient": "Unarchive Client", "unarchiveClientDescription": "The device has been unarchived", "block": "Block", "unblock": "Unblock", "deviceActions": "Device Actions", "deviceActionsDescription": "Manage device status and access", "devicePendingApprovalBannerDescription": "This device is pending approval. It won't be able to connect to resources until approved.", "connected": "Connected", "disconnected": "Disconnected", "approvalsEmptyStateTitle": "Device Approvals Not Enabled", "approvalsEmptyStateDescription": "Enable device approvals for roles to require admin approval before users can connect new devices.", "approvalsEmptyStateStep1Title": "Go to Roles", "approvalsEmptyStateStep1Description": "Navigate to your organization's roles settings to configure device approvals.", "approvalsEmptyStateStep2Title": "Enable Device Approvals", "approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.", "approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review", "approvalsEmptyStateButtonText": "Manage Roles", "domainErrorTitle": "We are having trouble verifying your domain" } ================================================ FILE: messages/es-ES.json ================================================ { "setupCreate": "Crear la organización, el sitio y los recursos", "headerAuthCompatibilityInfo": "Habilite esto para forzar una respuesta 401 no autorizada cuando falte un token de autenticación. Esto es necesario para navegadores o bibliotecas HTTP específicas que no envían credenciales sin un desafío del servidor.", "headerAuthCompatibility": "Compatibilidad extendida", "setupNewOrg": "Nueva organización", "setupCreateOrg": "Crear organización", "setupCreateResources": "Crear Recursos", "setupOrgName": "Nombre de la organización", "orgDisplayName": "Este es el nombre mostrado de la organización.", "orgId": "ID de la organización", "setupIdentifierMessage": "Este es el identificador único para la organización.", "setupErrorIdentifier": "El ID de la organización ya está en uso. Por favor, elija uno diferente.", "componentsErrorNoMemberCreate": "Actualmente no eres miembro de ninguna organización. Crea una organización para empezar.", "componentsErrorNoMember": "Actualmente no eres miembro de ninguna organización.", "welcome": "Bienvenido a Pangolin", "welcomeTo": "Bienvenido a", "componentsCreateOrg": "Crear una organización", "componentsMember": "Eres un miembro de {count, plural, =0 {ninguna organización} one {una organización} other {# organizaciones}}.", "componentsInvalidKey": "Se han detectado claves de licencia inválidas o caducadas. Siga los términos de licencia para seguir usando todas las características.", "dismiss": "Descartar", "subscriptionViolationMessage": "Estás más allá de tus límites para tu plan actual. Corrija el problema eliminando sitios, usuarios u otros recursos para permanecer dentro de tu plan.", "subscriptionViolationViewBilling": "Ver facturación", "componentsLicenseViolation": "Violación de la Licencia: Este servidor está usando sitios {usedSites} que exceden su límite de licencias de sitios {maxSites} . Siga los términos de licencia para seguir usando todas las características.", "componentsSupporterMessage": "¡Gracias por apoyar a Pangolin como {tier}!", "inviteErrorNotValid": "Lo sentimos, pero parece que la invitación a la que intentas acceder no ha sido aceptada o ya no es válida.", "inviteErrorUser": "Lo sentimos, pero parece que la invitación a la que intentas acceder no es para este usuario.", "inviteLoginUser": "Por favor, asegúrese de que ha iniciado sesión como el usuario correcto.", "inviteErrorNoUser": "Lo sentimos, pero parece que la invitación a la que intentas acceder no es para un usuario que existe.", "inviteCreateUser": "Por favor, cree una cuenta primero.", "goHome": "Ir a casa", "inviteLogInOtherUser": "Iniciar sesión como un usuario diferente", "createAnAccount": "Crear una cuenta", "inviteNotAccepted": "Invitación no aceptada", "authCreateAccount": "Crear una cuenta para empezar", "authNoAccount": "¿No tienes una cuenta?", "email": "E-mail", "password": "Contraseña", "confirmPassword": "Confirmar contraseña", "createAccount": "Crear cuenta", "viewSettings": "Ver configuraciones", "delete": "Eliminar", "name": "Nombre", "online": "En línea", "offline": "Desconectado", "site": "Sitio", "dataIn": "Datos en", "dataOut": "Datos Fuentes", "connectionType": "Tipo de conexión", "tunnelType": "Tipo de túnel", "local": "Local", "edit": "Editar", "siteConfirmDelete": "Confirmar Borrar Sitio", "siteDelete": "Eliminar sitio", "siteMessageRemove": "Una vez eliminado, el sitio ya no será accesible. Todos los objetivos asociados con el sitio también serán eliminados.", "siteQuestionRemove": "¿Está seguro que desea eliminar el sitio de la organización?", "siteManageSites": "Administrar Sitios", "siteDescription": "Crear y administrar sitios para permitir la conectividad a redes privadas", "sitesBannerTitle": "Conectar cualquier red", "sitesBannerDescription": "Un sitio es una conexión a una red remota que permite a Pangolin proporcionar acceso a recursos, públicos o privados, a usuarios en cualquier lugar. Instale el conector de red del sitio (Newt) en cualquier lugar donde pueda ejecutar un binario o contenedor para establecer la conexión.", "sitesBannerButtonText": "Instalar sitio", "approvalsBannerTitle": "Aprobar o denegar el acceso al dispositivo", "approvalsBannerDescription": "Revisar y aprobar o denegar las solicitudes de acceso al dispositivo de los usuarios. Cuando se requieren aprobaciones de dispositivos, los usuarios deben obtener la aprobación del administrador antes de que sus dispositivos puedan conectarse a los recursos de su organización.", "approvalsBannerButtonText": "Saber más", "siteCreate": "Crear sitio", "siteCreateDescription2": "Siga los pasos siguientes para crear y conectar un nuevo sitio", "siteCreateDescription": "Crear un nuevo sitio para empezar a conectar recursos", "close": "Cerrar", "siteErrorCreate": "Error al crear el sitio", "siteErrorCreateKeyPair": "Por defecto no se encuentra el par de claves o el sitio", "siteErrorCreateDefaults": "Sitio por defecto no encontrado", "method": "Método", "siteMethodDescription": "Así es como se expondrán las conexiones.", "siteLearnNewt": "Aprende cómo instalar Newt en tu sistema", "siteSeeConfigOnce": "Sólo podrá ver la configuración una vez.", "siteLoadWGConfig": "Cargando configuración de WireGuard...", "siteDocker": "Expandir para detalles de despliegue de Docker", "toggle": "Cambiar", "dockerCompose": "Componer Docker", "dockerRun": "Ejecutar Docker", "siteLearnLocal": "Los sitios locales no tienen túnel, aprender más", "siteConfirmCopy": "He copiado la configuración", "searchSitesProgress": "Buscar sitios...", "siteAdd": "Añadir sitio", "siteInstallNewt": "Instalar Newt", "siteInstallNewtDescription": "Recibe Newt corriendo en tu sistema", "WgConfiguration": "Configuración de Wirex Guard", "WgConfigurationDescription": "Utilice la siguiente configuración para conectarse a la red", "operatingSystem": "Sistema operativo", "commands": "Comandos", "recommended": "Recomendado", "siteNewtDescription": "Para la mejor experiencia de usuario, utilice Newt. Utiliza Wirex Guard bajo la capa y te permite dirigirte a tus recursos privados mediante su dirección LAN en tu red privada desde el panel de control de Pangolin.", "siteRunsInDocker": "Ejecutar en Docker", "siteRunsInShell": "Ejecuta en el shell en macOS, Linux y Windows", "siteErrorDelete": "Error al eliminar el sitio", "siteErrorUpdate": "Error al actualizar el sitio", "siteErrorUpdateDescription": "Se ha producido un error al actualizar el sitio.", "siteUpdated": "Sitio actualizado", "siteUpdatedDescription": "El sitio ha sido actualizado.", "siteGeneralDescription": "Configurar la configuración general de este sitio", "siteSettingDescription": "Configurar los ajustes en el sitio", "siteSetting": "Ajustes {siteName}", "siteNewtTunnel": "Sitio nuevo (recomendado)", "siteNewtTunnelDescription": "La forma más fácil de crear un punto de entrada en cualquier red. Sin configuración extra.", "siteWg": "Wirex Guardia Básica", "siteWgDescription": "Utilice cualquier cliente Wirex Guard para establecer un túnel. Se requiere una configuración manual de NAT.", "siteWgDescriptionSaas": "Utilice cualquier cliente de WireGuard para establecer un túnel. Se requiere configuración manual de NAT. SOLO FUNCIONA EN NODOS AUTOGESTIONADOS", "siteLocalDescription": "Solo recursos locales. Sin túneles.", "siteLocalDescriptionSaas": "Solo recursos locales. No hay túneles. Sólo disponible en nodos remotos.", "siteSeeAll": "Ver todos los sitios", "siteTunnelDescription": "Determina cómo quieres conectarte al sitio", "siteNewtCredentials": "Credenciales", "siteNewtCredentialsDescription": "Así es como el sitio se autentificará con el servidor", "remoteNodeCredentialsDescription": "Así es como el nodo remoto se autentificará con el servidor", "siteCredentialsSave": "Guardar las credenciales", "siteCredentialsSaveDescription": "Sólo podrás verlo una vez. Asegúrate de copiarlo a un lugar seguro.", "siteInfo": "Información del sitio", "status": "Estado", "shareTitle": "Administrar Enlaces de Compartir", "shareDescription": "Crear enlaces compartidos para conceder acceso temporal o permanente a recursos proxy", "shareSearch": "Buscar enlaces compartidos...", "shareCreate": "Crear enlace Compartir", "shareErrorDelete": "Error al eliminar el enlace", "shareErrorDeleteMessage": "Se ha producido un error al eliminar el enlace", "shareDeleted": "Enlace eliminado", "shareDeletedDescription": "El enlace ha sido eliminado", "shareTokenDescription": "El token de acceso puede ser pasado de dos maneras: como parámetro de consulta o en las cabeceras de solicitud. Estos deben ser pasados del cliente en cada solicitud de acceso autenticado.", "accessToken": "Token de acceso", "usageExamples": "Ejemplos de uso", "tokenId": "ID de token", "requestHeades": "Solicitar cabeceras", "queryParameter": "Parámetro de consulta", "importantNote": "Nota Importante", "shareImportantDescription": "Por razones de seguridad, el uso de cabeceras se recomienda sobre parámetros de consulta cuando sea posible, ya que los parámetros de consulta pueden ser registrados en los registros del servidor o en el historial del navegador.", "token": "Token", "shareTokenSecurety": "Mantenga seguro el token de acceso. No lo comparta en áreas de acceso público o código del lado del cliente.", "shareErrorFetchResource": "No se pudo obtener recursos", "shareErrorFetchResourceDescription": "Se ha producido un error al recuperar los recursos", "shareErrorCreate": "Error al crear el enlace compartir", "shareErrorCreateDescription": "Se ha producido un error al crear el enlace compartido", "shareCreateDescription": "Cualquiera con este enlace puede acceder al recurso", "shareTitleOptional": "Título (opcional)", "expireIn": "Caduca en", "neverExpire": "Nunca expirar", "shareExpireDescription": "El tiempo de caducidad es cuánto tiempo el enlace será utilizable y proporcionará acceso al recurso. Después de este tiempo, el enlace ya no funcionará, y los usuarios que usaron este enlace perderán el acceso al recurso.", "shareSeeOnce": "Sólo podrás ver este enlace una vez. Asegúrate de copiarlo.", "shareAccessHint": "Cualquiera con este enlace puede acceder al recurso. Compártelo con cuidado.", "shareTokenUsage": "Ver Uso de Token de Acceso", "createLink": "Crear enlace", "resourcesNotFound": "No se encontraron recursos", "resourceSearch": "Buscar recursos", "openMenu": "Abrir menú", "resource": "Recurso", "title": "Título", "created": "Creado", "expires": "Caduca", "never": "Nunca", "shareErrorSelectResource": "Por favor, seleccione un recurso", "proxyResourceTitle": "Administrar recursos públicos", "proxyResourceDescription": "Crear y administrar recursos que sean accesibles públicamente a través de un navegador web", "proxyResourcesBannerTitle": "Acceso público basado en web", "proxyResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.", "clientResourceTitle": "Administrar recursos privados", "clientResourceDescription": "Crear y administrar recursos que sólo son accesibles a través de un cliente conectado", "privateResourcesBannerTitle": "Acceso privado de confianza cero", "privateResourcesBannerDescription": "Los recursos privados usan seguridad de confianza cero, asegurando que usuarios y máquinas solo puedan acceder a los recursos que usted conceda explícitamente. Conecte dispositivos de usuario o clientes de máquinas para acceder a estos recursos a través de una red privada virtual segura.", "resourcesSearch": "Buscar recursos...", "resourceAdd": "Añadir Recurso", "resourceErrorDelte": "Error al eliminar el recurso", "authentication": "Autenticación", "protected": "Protegido", "notProtected": "No protegido", "resourceMessageRemove": "Una vez eliminado, el recurso ya no será accesible. Todos los objetivos asociados con el recurso también serán eliminados.", "resourceQuestionRemove": "¿Está seguro que desea eliminar el recurso de la organización?", "resourceHTTP": "HTTPS Recurso", "resourceHTTPDescription": "Proxy proporciona solicitudes sobre HTTPS usando un nombre de dominio completamente calificado.", "resourceRaw": "Recurso TCP/UDP sin procesar", "resourceRawDescription": "Proxy proporciona solicitudes sobre TCP/UDP usando un número de puerto.", "resourceRawDescriptionCloud": "Las peticiones de proxy sobre TCP/UDP crudas usando un número de puerto. REQUIERE EL USO DE UN NODO REMOTE.", "resourceCreate": "Crear Recurso", "resourceCreateDescription": "Siga los siguientes pasos para crear un nuevo recurso", "resourceSeeAll": "Ver todos los recursos", "resourceInfo": "Información del recurso", "resourceNameDescription": "Este es el nombre para mostrar el recurso.", "siteSelect": "Seleccionar sitio", "siteSearch": "Buscar sitio", "siteNotFound": "Sitio no encontrado.", "selectCountry": "Seleccionar país", "searchCountries": "Buscar países...", "noCountryFound": "Ningún país encontrado.", "siteSelectionDescription": "Este sitio proporcionará conectividad al objetivo.", "resourceType": "Tipo de recurso", "resourceTypeDescription": "Determina cómo acceder al recurso", "resourceHTTPSSettings": "Configuración HTTPS", "resourceHTTPSSettingsDescription": "Configurar cómo se accederá al recurso a través de HTTPS", "domainType": "Tipo de dominio", "subdomain": "Subdominio", "baseDomain": "Dominio base", "subdomnainDescription": "El subdominio al que el recurso será accesible.", "resourceRawSettings": "Configuración TCP/UDP", "resourceRawSettingsDescription": "Configurar cómo se accederá al recurso a través de TCP/UDP", "protocol": "Protocolo", "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", "resourceAddEntrypoints": "Traefik: Añadir puntos de entrada", "resourceExposePorts": "Gerbil: Exponer puertos en Docker Compose", "resourceLearnRaw": "Aprende cómo configurar los recursos TCP/UDP", "resourceBack": "Volver a Recursos", "resourceGoTo": "Ir a Recurso", "resourceDelete": "Eliminar Recurso", "resourceDeleteConfirm": "Confirmar Borrar Recurso", "visibility": "Visibilidad", "enabled": "Activado", "disabled": "Deshabilitado", "general": "General", "generalSettings": "Configuración General", "proxy": "Proxy", "internal": "Interno", "rules": "Reglas", "resourceSettingDescription": "Configurar la configuración del recurso", "resourceSetting": "Ajustes {resourceName}", "alwaysAllow": "Autorización Bypass", "alwaysDeny": "Bloquear acceso", "passToAuth": "Pasar a Autenticación", "orgSettingsDescription": "Configurar la configuración de la organización", "orgGeneralSettings": "Configuración de la organización", "orgGeneralSettingsDescription": "Administrar los detalles y la configuración de la organización", "saveGeneralSettings": "Guardar ajustes generales", "saveSettings": "Guardar ajustes", "orgDangerZone": "Zona de peligro", "orgDangerZoneDescription": "Una vez que elimines este órgano, no hay vuelta atrás. Por favor, asegúrate de ello.", "orgDelete": "Eliminar organización", "orgDeleteConfirm": "Confirmar eliminación de organización", "orgMessageRemove": "Esta acción es irreversible y eliminará todos los datos asociados.", "orgMessageConfirm": "Para confirmar, por favor escriba el nombre de la organización a continuación.", "orgQuestionRemove": "¿Está seguro que desea eliminar la organización?", "orgUpdated": "Organización actualizada", "orgUpdatedDescription": "La organización ha sido actualizada.", "orgErrorUpdate": "Error al actualizar la organización", "orgErrorUpdateMessage": "Se ha producido un error al actualizar la organización.", "orgErrorFetch": "Error al recuperar organizaciones", "orgErrorFetchMessage": "Se ha producido un error al listar sus organizaciones", "orgErrorDelete": "Error al eliminar la organización", "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", "accessUsersDescription": "Invitar y administrar usuarios con acceso a esta organización", "accessUsersSearch": "Buscar usuarios...", "accessUserCreate": "Crear usuario", "accessUserRemove": "Eliminar usuario", "username": "Usuario", "identityProvider": "Proveedor de identidad", "role": "Rol", "nameRequired": "Se requiere nombre", "accessRolesManage": "Administrar roles", "accessRolesDescription": "Crear y administrar roles para usuarios en la organización", "accessRolesSearch": "Buscar roles...", "accessRolesAdd": "Añadir rol", "accessRoleDelete": "Eliminar rol", "accessApprovalsManage": "Administrar aprobaciones", "accessApprovalsDescription": "Ver y administrar aprobaciones pendientes para el acceso a esta organización", "description": "Descripción", "inviteTitle": "Invitaciones abiertas", "inviteDescription": "Administrar invitaciones para que otros usuarios se unan a la organización", "inviteSearch": "Buscar invitaciones...", "minutes": "Minutos", "hours": "Horas", "days": "Días", "weeks": "Semanas", "months": "Meses", "years": "Años", "day": "{count, plural, one {# día} other {# días}}", "apiKeysTitle": "Información de Clave API", "apiKeysConfirmCopy2": "Debes confirmar que has copiado la clave API.", "apiKeysErrorCreate": "Error al crear la clave API", "apiKeysErrorSetPermission": "Error al establecer permisos", "apiKeysCreate": "Generar clave API", "apiKeysCreateDescription": "Generar una nueva clave API para la organización", "apiKeysGeneralSettings": "Permisos", "apiKeysGeneralSettingsDescription": "Determinar qué puede hacer esta clave API", "apiKeysList": "Nueva Clave API", "apiKeysSave": "Guardar la clave API", "apiKeysSaveDescription": "Sólo podrás verlo una vez. Asegúrate de copiarlo a un lugar seguro.", "apiKeysInfo": "La clave API es:", "apiKeysConfirmCopy": "He copiado la clave API", "generate": "Generar", "done": "Hecho", "apiKeysSeeAll": "Ver todas las claves API", "apiKeysPermissionsErrorLoadingActions": "Error al cargar las acciones clave API", "apiKeysPermissionsErrorUpdate": "Error al establecer permisos", "apiKeysPermissionsUpdated": "Permisos actualizados", "apiKeysPermissionsUpdatedDescription": "Los permisos han sido actualizados.", "apiKeysPermissionsGeneralSettings": "Permisos", "apiKeysPermissionsGeneralSettingsDescription": "Determinar qué puede hacer esta clave API", "apiKeysPermissionsSave": "Guardar permisos", "apiKeysPermissionsTitle": "Permisos", "apiKeys": "Claves API", "searchApiKeys": "Buscar claves API...", "apiKeysAdd": "Generar clave API", "apiKeysErrorDelete": "Error al eliminar la clave API", "apiKeysErrorDeleteMessage": "Error al eliminar la clave API", "apiKeysQuestionRemove": "¿Está seguro que desea eliminar la clave API de la organización?", "apiKeysMessageRemove": "Una vez eliminada, la clave API ya no podrá ser utilizada.", "apiKeysDeleteConfirm": "Confirmar Borrar Clave API", "apiKeysDelete": "Borrar Clave API", "apiKeysManage": "Administrar claves API", "apiKeysDescription": "Las claves API se utilizan para autenticar con la API de integración", "apiKeysSettings": "Ajustes {apiKeyName}", "userTitle": "Administrar todos los usuarios", "userDescription": "Ver y administrar todos los usuarios en el sistema", "userAbount": "Acerca de Gestión de Usuarios", "userAbountDescription": "Esta tabla muestra todos los objetos de usuario root en el sistema. Cada usuario puede pertenecer a varias organizaciones. Eliminar un usuario de una organización no elimina su objeto de usuario root - permanecerán en el sistema. Para eliminar completamente un usuario del sistema, debe eliminar su objeto de usuario root usando la acción de borrar en esta tabla.", "userServer": "Usuarios del servidor", "userSearch": "Buscar usuarios del servidor...", "userErrorDelete": "Error al eliminar el usuario", "userDeleteConfirm": "Confirmar Borrar Usuario", "userDeleteServer": "Eliminar usuario del servidor", "userMessageRemove": "El usuario será eliminado de todas las organizaciones y será eliminado completamente del servidor.", "userQuestionRemove": "¿Está seguro que desea eliminar permanentemente al usuario del servidor?", "licenseKey": "Clave de licencia", "valid": "Válido", "numberOfSites": "Número de sitios", "licenseKeySearch": "Buscar claves de licencia...", "licenseKeyAdd": "Añadir clave de licencia", "type": "Tipo", "licenseKeyRequired": "La clave de licencia es necesaria", "licenseTermsAgree": "Debe aceptar los términos de la licencia", "licenseErrorKeyLoad": "Error al cargar las claves de licencia", "licenseErrorKeyLoadDescription": "Se ha producido un error al cargar las claves de licencia.", "licenseErrorKeyDelete": "Error al eliminar la clave de licencia", "licenseErrorKeyDeleteDescription": "Se ha producido un error al eliminar la clave de licencia.", "licenseKeyDeleted": "Clave de licencia eliminada", "licenseKeyDeletedDescription": "La clave de licencia ha sido eliminada.", "licenseErrorKeyActivate": "Error al activar la clave de licencia", "licenseErrorKeyActivateDescription": "Se ha producido un error al activar la clave de licencia.", "licenseAbout": "Acerca de la licencia", "communityEdition": "Edición comunitaria", "licenseAboutDescription": "Esto es para usuarios empresariales y empresariales que utilizan Pangolin en un entorno comercial. Si estás usando Pangolin para uso personal, puedes ignorar esta sección.", "licenseKeyActivated": "Clave de licencia activada", "licenseKeyActivatedDescription": "La clave de licencia se ha activado correctamente.", "licenseErrorKeyRecheck": "Error al revisar las claves de licencia", "licenseErrorKeyRecheckDescription": "Se ha producido un error al revisar las claves de licencia.", "licenseErrorKeyRechecked": "Claves de licencia remarcadas", "licenseErrorKeyRecheckedDescription": "Todas las claves de licencia han sido revisadas", "licenseActivateKey": "Activar clave de licencia", "licenseActivateKeyDescription": "Introduzca una clave de licencia para activarla.", "licenseActivate": "Activar licencia", "licenseAgreement": "Al marcar esta casilla, confirma que ha leído y aceptado los términos de licencia correspondientes al nivel asociado con su clave de licencia.", "fossorialLicense": "Ver Términos de suscripción y licencia comercial", "licenseMessageRemove": "Esto eliminará la clave de licencia y todos los permisos asociados otorgados por ella.", "licenseMessageConfirm": "Para confirmar, por favor escriba la clave de licencia a continuación.", "licenseQuestionRemove": "¿Está seguro que desea eliminar la clave de licencia?", "licenseKeyDelete": "Eliminar clave de licencia", "licenseKeyDeleteConfirm": "Confirmar eliminar clave de licencia", "licenseTitle": "Administrar estado de licencia", "licenseTitleDescription": "Ver y administrar claves de licencia en el sistema", "licenseHost": "Licencia de host", "licenseHostDescription": "Administrar la clave de licencia principal para el host.", "licensedNot": "Sin licencia", "hostId": "ID del Host", "licenseReckeckAll": "Revisar todas las claves", "licenseSiteUsage": "Uso de Sitios", "licenseSiteUsageDecsription": "Ver el número de sitios que utilizan esta licencia.", "licenseNoSiteLimit": "No hay límite en el número de sitios que utilizan un host sin licencia.", "licensePurchase": "Comprar Licencia", "licensePurchaseSites": "Comprar sitios adicionales", "licenseSitesUsedMax": "{usedSites} de {maxSites} sitios usados", "licenseSitesUsed": "{count, plural, =0 {# sitios} one {# sitio} other {# sitios}} en el sistema.", "licensePurchaseDescription": "Elige cuántos sitios quieres {selectedMode, select, license {compra una licencia para. Siempre puedes añadir más sitios más tarde.} other {añadir a tu licencia existente.}}", "licenseFee": "Tarifa de licencia", "licensePriceSite": "Precio por sitio", "total": "Total", "licenseContinuePayment": "Continuar con el pago", "pricingPage": "página de precios", "pricingPortal": "Ver Portal de Compra", "licensePricingPage": "Para obtener los precios y descuentos más actualizados, por favor visite el ", "invite": "Invitaciones", "inviteRegenerate": "Regenerar invitación", "inviteRegenerateDescription": "Revocar invitación anterior y crear una nueva", "inviteRemove": "Eliminar invitación", "inviteRemoveError": "Error al eliminar la invitación", "inviteRemoveErrorDescription": "Ocurrió un error mientras se eliminaba la invitación.", "inviteRemoved": "Invitación eliminada", "inviteRemovedDescription": "La invitación para {email} ha sido eliminada.", "inviteQuestionRemove": "¿Está seguro de que desea eliminar la invitación?", "inviteMessageRemove": "Una vez eliminada, esta invitación ya no será válida. Siempre puede volver a invitar al usuario más tarde.", "inviteMessageConfirm": "Para confirmar, por favor escriba la dirección de correo electrónico de la invitación a continuación.", "inviteQuestionRegenerate": "¿Estás seguro de que quieres regenerar la invitación para {email}? Esto revocará la invitación anterior.", "inviteRemoveConfirm": "Confirmar eliminación de invitación", "inviteRegenerated": "Invitación Regenerada", "inviteSent": "Se ha enviado una nueva invitación a {email}.", "inviteSentEmail": "Enviar notificación por correo electrónico al usuario", "inviteGenerate": "Se ha generado una nueva invitación para {email}.", "inviteDuplicateError": "Invitación duplicada", "inviteDuplicateErrorDescription": "Ya existe una invitación para este usuario.", "inviteRateLimitError": "Límite de tasa excedido", "inviteRateLimitErrorDescription": "Has superado el límite de 3 regeneraciones por hora. Inténtalo de nuevo más tarde.", "inviteRegenerateError": "No se pudo regenerar la invitación", "inviteRegenerateErrorDescription": "Se ha producido un error al regenerar la invitación.", "inviteValidityPeriod": "Periodo de validez", "inviteValidityPeriodSelect": "Seleccionar período de validez", "inviteRegenerateMessage": "La invitación ha sido regenerada. El usuario debe acceder al enlace de abajo para aceptar la invitación.", "inviteRegenerateButton": "Regenerar", "expiresAt": "Caduca el", "accessRoleUnknown": "Rol desconocido", "placeholder": "Marcador de posición", "userErrorOrgRemove": "Error al eliminar el usuario", "userErrorOrgRemoveDescription": "Ocurrió un error mientras se eliminaba el usuario.", "userOrgRemoved": "Usuario eliminado", "userOrgRemovedDescription": "El usuario {email} ha sido eliminado de la organización.", "userQuestionOrgRemove": "¿Está seguro que desea eliminar este usuario de la organización?", "userMessageOrgRemove": "Una vez eliminado, este usuario ya no tendrá acceso a la organización. Siempre puede volver a invitarlos más tarde, pero tendrán que aceptar la invitación de nuevo.", "userRemoveOrgConfirm": "Confirmar eliminar usuario", "userRemoveOrg": "Eliminar usuario de la organización", "users": "Usuarios", "accessRoleMember": "Miembro", "accessRoleOwner": "Propietario", "userConfirmed": "Confirmada", "idpNameInternal": "Interno", "emailInvalid": "Dirección de correo inválida", "inviteValidityDuration": "Por favor, seleccione una duración", "accessRoleSelectPlease": "Por favor, seleccione un rol", "usernameRequired": "Nombre de usuario requerido", "idpSelectPlease": "Por favor, seleccione un proveedor de identidad", "idpGenericOidc": "Proveedor OAuth2/OIDC genérico.", "accessRoleErrorFetch": "Error al recuperar roles", "accessRoleErrorFetchDescription": "Se ha producido un error al recuperar los roles", "idpErrorFetch": "Error al recuperar proveedores de identidad", "idpErrorFetchDescription": "Se ha producido un error al recuperar proveedores de identidad", "userErrorExists": "El usuario ya existe", "userErrorExistsDescription": "Este usuario ya es miembro de la organización.", "inviteError": "Error al invitar al usuario", "inviteErrorDescription": "Ocurrió un error mientras se invitaba al usuario", "userInvited": "Usuario invitado", "userInvitedDescription": "El usuario ha sido invitado con éxito.", "userErrorCreate": "Error al crear el usuario", "userErrorCreateDescription": "Se ha producido un error al crear el usuario", "userCreated": "Usuario creado", "userCreatedDescription": "El usuario se ha creado correctamente.", "userTypeInternal": "Usuario interno", "userTypeInternalDescription": "Invitar a un usuario a unirse a la organización directamente.", "userTypeExternal": "Usuario externo", "userTypeExternalDescription": "Crear un usuario con un proveedor de identidad externo.", "accessUserCreateDescription": "Siga los pasos siguientes para crear un nuevo usuario", "userSeeAll": "Ver todos los usuarios", "userTypeTitle": "Tipo de usuario", "userTypeDescription": "Determina cómo quieres crear el usuario", "userSettings": "Información del usuario", "userSettingsDescription": "Introduzca los detalles del nuevo usuario", "inviteEmailSent": "Enviar correo de invitación al usuario", "inviteValid": "Válido para", "selectDuration": "Seleccionar duración", "selectResource": "Seleccionar Recurso", "filterByResource": "Filtrar por Recurso", "selectApprovalState": "Seleccionar Estado de Aprobación", "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", "deniedApproval": "Aprobación denegada", "all": "Todo", "deny": "Denegar", "viewDetails": "Ver detalles", "requestingNewDeviceApproval": "solicitó un nuevo dispositivo", "resetFilters": "Reiniciar filtros", "totalBlocked": "Solicitudes bloqueadas por Pangolin", "totalRequests": "Solicitudes totales", "requestsByCountry": "Solicitudes por país", "requestsByDay": "Solicitudes por día", "blocked": "Bloqueado", "allowed": "Permitido", "topCountries": "Top Países", "accessRoleSelect": "Seleccionar rol", "inviteEmailSentDescription": "Se ha enviado un correo electrónico al usuario con el siguiente enlace de acceso. Debe acceder al enlace para aceptar la invitación.", "inviteSentDescription": "El usuario ha sido invitado. Debe acceder al enlace de abajo para aceptar la invitación.", "inviteExpiresIn": "La invitación expirará en {days, plural, one {# día} other {# días}}.", "idpTitle": "Proveedor de identidad", "idpSelect": "Seleccione el proveedor de identidad para el usuario externo", "idpNotConfigured": "No hay proveedores de identidad configurados. Por favor, configure un proveedor de identidad antes de crear usuarios externos.", "usernameUniq": "Esto debe coincidir con el nombre de usuario único que existe en el proveedor de identidad seleccionado.", "emailOptional": "Email (opcional)", "nameOptional": "Nombre (opcional)", "accessControls": "Controles de acceso", "userDescription2": "Administrar la configuración de este usuario", "accessRoleErrorAdd": "No se pudo agregar el usuario al rol", "accessRoleErrorAddDescription": "Ocurrió un error mientras se añadía el usuario al rol.", "userSaved": "Usuario guardado", "userSavedDescription": "El usuario ha sido actualizado.", "autoProvisioned": "Auto asegurado", "autoProvisionedDescription": "Permitir a este usuario ser administrado automáticamente por el proveedor de identidad", "accessControlsDescription": "Administrar lo que este usuario puede acceder y hacer en la organización", "accessControlsSubmit": "Guardar controles de acceso", "roles": "Roles", "accessUsersRoles": "Administrar usuarios y roles", "accessUsersRolesDescription": "Invitar usuarios y añadirlos a roles para administrar el acceso a la organización", "key": "Clave", "createdAt": "Creado el", "proxyErrorInvalidHeader": "Valor de cabecera de host personalizado no válido. Utilice el formato de nombre de dominio, o guarde en blanco para desestablecer cabecera de host personalizada.", "proxyErrorTls": "Nombre de servidor TLS inválido. Utilice el formato de nombre de dominio o guarde en blanco para eliminar el nombre de servidor TLS.", "proxyEnableSSL": "Activar SSL", "proxyEnableSSLDescription": "Habilita el cifrado SSL/TLS para conexiones seguras HTTPS a los objetivos.", "target": "Target", "configureTarget": "Configurar objetivos", "targetErrorFetch": "Error al recuperar los objetivos", "targetErrorFetchDescription": "Se ha producido un error al recuperar los objetivos", "siteErrorFetch": "No se pudo obtener el recurso", "siteErrorFetchDescription": "Se ha producido un error al recuperar el recurso", "targetErrorDuplicate": "Objetivo duplicado", "targetErrorDuplicateDescription": "Ya existe un objetivo con estos ajustes", "targetWireGuardErrorInvalidIp": "IP de destino no válida", "targetWireGuardErrorInvalidIpDescription": "La IP de destino debe estar dentro de la subred del sitio", "targetsUpdated": "Objetivos actualizados", "targetsUpdatedDescription": "Objetivos y ajustes actualizados correctamente", "targetsErrorUpdate": "Error al actualizar los objetivos", "targetsErrorUpdateDescription": "Se ha producido un error al actualizar los objetivos", "targetTlsUpdate": "Ajustes TLS actualizados", "targetTlsUpdateDescription": "Los ajustes de TLS se han actualizado correctamente", "targetErrorTlsUpdate": "Error al actualizar los ajustes de TLS", "targetErrorTlsUpdateDescription": "Ocurrió un error mientras se actualizaban los ajustes de TLS", "proxyUpdated": "Configuración del proxy actualizada", "proxyUpdatedDescription": "La configuración del proxy se ha actualizado correctamente", "proxyErrorUpdate": "Error al actualizar la configuración del proxy", "proxyErrorUpdateDescription": "Se ha producido un error al actualizar la configuración del proxy", "targetAddr": "Anfitrión", "targetPort": "Puerto", "targetProtocol": "Protocolo", "targetTlsSettings": "Configuración de conexión segura", "targetTlsSettingsDescription": "Configurar ajustes SSL/TLS para el recurso", "targetTlsSettingsAdvanced": "Ajustes avanzados de TLS", "targetTlsSni": "Nombre del servidor TLS", "targetTlsSniDescription": "El nombre del servidor TLS a usar para SNI. Deje en blanco para usar el valor predeterminado.", "targetTlsSubmit": "Guardar ajustes", "targets": "Configuración de objetivos", "targetsDescription": "Establecer objetivos para enrutar tráfico a servicios de backend", "targetStickySessions": "Activar Sesiones Pegadas", "targetStickySessionsDescription": "Mantener conexiones en el mismo objetivo de backend para toda su sesión.", "methodSelect": "Seleccionar método", "targetSubmit": "Añadir destino", "targetNoOne": "Este recurso no tiene ningún objetivo. Agrega un objetivo para configurar dónde enviar peticiones al backend.", "targetNoOneDescription": "Si se añade más de un objetivo anterior se activará el balance de carga.", "targetsSubmit": "Guardar objetivos", "addTarget": "Añadir destino", "targetErrorInvalidIp": "Dirección IP inválida", "targetErrorInvalidIpDescription": "Por favor, introduzca una dirección IP válida o nombre de host", "targetErrorInvalidPort": "Puerto inválido", "targetErrorInvalidPortDescription": "Por favor, introduzca un número de puerto válido", "targetErrorNoSite": "Ningún sitio seleccionado", "targetErrorNoSiteDescription": "Por favor, seleccione un sitio para el objetivo", "targetCreated": "Objetivo creado", "targetCreatedDescription": "El objetivo se ha creado correctamente", "targetErrorCreate": "Error al crear el objetivo", "targetErrorCreateDescription": "Se ha producido un error al crear el objetivo", "tlsServerName": "Nombre del servidor TLS", "tlsServerNameDescription": "El nombre del servidor TLS a usar para SNI", "save": "Guardar", "proxyAdditional": "Ajustes adicionales del proxy", "proxyAdditionalDescription": "Configurar cómo maneja el recurso la configuración del proxy", "proxyCustomHeader": "Cabecera de host personalizada", "proxyCustomHeaderDescription": "La cabecera del host a establecer cuando se realizan peticiones de reemplazo. Deje en blanco para usar el valor predeterminado.", "proxyAdditionalSubmit": "Guardar ajustes de proxy", "subnetMaskErrorInvalid": "Máscara de subred inválida. Debe estar entre 0 y 32.", "ipAddressErrorInvalidFormat": "Formato de dirección IP inválido", "ipAddressErrorInvalidOctet": "Octet de dirección IP no válido", "path": "Ruta", "matchPath": "Coincidir ruta", "ipAddressRange": "Rango IP", "rulesErrorFetch": "Error al obtener las reglas", "rulesErrorFetchDescription": "Se ha producido un error al recuperar las reglas", "rulesErrorDuplicate": "Duplicar regla", "rulesErrorDuplicateDescription": "Ya existe una regla con estos ajustes", "rulesErrorInvalidIpAddressRange": "CIDR inválido", "rulesErrorInvalidIpAddressRangeDescription": "Por favor, introduzca un valor CIDR válido", "rulesErrorInvalidUrl": "Ruta URL inválida", "rulesErrorInvalidUrlDescription": "Por favor, introduzca un valor de ruta de URL válido", "rulesErrorInvalidIpAddress": "IP inválida", "rulesErrorInvalidIpAddressDescription": "Por favor, introduzca una dirección IP válida", "rulesErrorUpdate": "Error al actualizar las reglas", "rulesErrorUpdateDescription": "Se ha producido un error al actualizar las reglas", "rulesUpdated": "Activar Reglas", "rulesUpdatedDescription": "La evaluación de la regla ha sido actualizada", "rulesMatchIpAddressRangeDescription": "Introduzca una dirección en formato CIDR (por ejemplo, 103.21.244.0/22)", "rulesMatchIpAddress": "Introduzca una dirección IP (por ejemplo, 103.21.244.12)", "rulesMatchUrl": "Introduzca una ruta URL o patrón (por ej., /api/v1/todos o /api/v1/*)", "rulesErrorInvalidPriority": "Prioridad inválida", "rulesErrorInvalidPriorityDescription": "Por favor, introduzca una prioridad válida", "rulesErrorDuplicatePriority": "Prioridades duplicadas", "rulesErrorDuplicatePriorityDescription": "Por favor, introduzca prioridades únicas", "ruleUpdated": "Reglas actualizadas", "ruleUpdatedDescription": "Reglas actualizadas correctamente", "ruleErrorUpdate": "Operación fallida", "ruleErrorUpdateDescription": "Se ha producido un error durante la operación de guardado", "rulesPriority": "Prioridad", "rulesAction": "Accin", "rulesMatchType": "Tipo de partida", "value": "Valor", "rulesAbout": "Sobre Reglas", "rulesAboutDescription": "Las reglas permiten controlar el acceso al recurso basado en un conjunto de criterios. Puede crear reglas para permitir o denegar el acceso basándose en la dirección IP o ruta de la URL.", "rulesActions": "Acciones", "rulesActionAlwaysAllow": "Permitir siempre: pasar todos los métodos de autenticación", "rulesActionAlwaysDeny": "Denegar siempre: Bloquear todas las peticiones; no se puede intentar autenticación", "rulesActionPassToAuth": "Pasar a Autenticación: Permitir que se intenten los métodos de autenticación", "rulesMatchCriteria": "Criterios coincidentes", "rulesMatchCriteriaIpAddress": "Coincidir con una dirección IP específica", "rulesMatchCriteriaIpAddressRange": "Coincide con un rango de direcciones IP en notación CIDR", "rulesMatchCriteriaUrl": "Coincidir con una ruta de URL o patrón", "rulesEnable": "Activar Reglas", "rulesEnableDescription": "Activar o desactivar la evaluación de reglas para este recurso", "rulesResource": "Configuración de reglas de recursos", "rulesResourceDescription": "Configurar reglas para controlar el acceso al recurso", "ruleSubmit": "Añadir Regla", "rulesNoOne": "No hay reglas. Agregue una regla usando el formulario.", "rulesOrder": "Las reglas son evaluadas por prioridad en orden ascendente.", "rulesSubmit": "Guardar Reglas", "resourceErrorCreate": "Error al crear recurso", "resourceErrorCreateDescription": "Se ha producido un error al crear el recurso", "resourceErrorCreateMessage": "Error al crear el recurso:", "resourceErrorCreateMessageDescription": "Se ha producido un error inesperado", "sitesErrorFetch": "Error obteniendo sitios", "sitesErrorFetchDescription": "Se ha producido un error al recuperar los sitios", "domainsErrorFetch": "Error obteniendo dominios", "domainsErrorFetchDescription": "Se ha producido un error al recuperar los dominios", "none": "Ninguna", "unknown": "Desconocido", "resources": "Recursos", "resourcesDescription": "Los recursos son proxies a las aplicaciones que se ejecutan en la red privada. Crea un recurso para cualquier servicio HTTP/HTTPS o TCP/UDP crudo en tu red privada. Cada recurso debe estar conectado a un sitio para permitir una conectividad privada y segura a través de un túnel encriptado de WireGuard.", "resourcesWireGuardConnect": "Conectividad segura con cifrado de Wirex Guard", "resourcesMultipleAuthenticationMethods": "Configurar múltiples métodos de autenticación", "resourcesUsersRolesAccess": "Control de acceso basado en usuarios y roles", "resourcesErrorUpdate": "Error al cambiar el recurso", "resourcesErrorUpdateDescription": "Se ha producido un error al actualizar el recurso", "access": "Acceder", "accessControl": "Control de acceso", "shareLink": "{resource} Compartir Enlace", "resourceSelect": "Seleccionar recurso", "shareLinks": "Compartir enlaces", "share": "Enlaces compartibles", "shareDescription2": "Crea enlaces compartidos a recursos. Los enlaces proporcionan acceso temporal o ilimitado a tu recurso. Puede configurar la duración de caducidad del enlace cuando cree uno.", "shareEasyCreate": "Fácil de crear y compartir", "shareConfigurableExpirationDuration": "Duración de caducidad configurable", "shareSecureAndRevocable": "Seguro y revocable", "nameMin": "El nombre debe tener al menos caracteres {len}.", "nameMax": "El nombre no debe tener más de {len} caracteres.", "sitesConfirmCopy": "Por favor, confirme que ha copiado la configuración.", "unknownCommand": "Comando desconocido", "newtErrorFetchReleases": "No se pudo obtener la información del lanzamiento: {err}", "newtErrorFetchLatest": "Error obteniendo la última versión: {err}", "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Secreto", "architecture": "Arquitectura", "sites": "Sitios", "siteWgAnyClients": "Usa cualquier cliente de Wirex para conectarte. Tendrás que dirigirte a los recursos internos usando la IP de compañeros.", "siteWgCompatibleAllClients": "Compatible con todos los clientes de Wirex Guard", "siteWgManualConfigurationRequired": "Configuración manual requerida", "userErrorNotAdminOrOwner": "El usuario no es un administrador o propietario", "pangolinSettings": "Ajustes - Pangolin", "accessRoleYour": "Tu rol:", "accessRoleSelect2": "Seleccionar roles", "accessUserSelect": "Seleccionar usuarios", "otpEmailEnter": "Escribe un email", "otpEmailEnterDescription": "Pulse Enter para añadir un correo electrónico después de teclearlo en el campo de entrada.", "otpEmailErrorInvalid": "Dirección de correo electrónico no válida. El comodín (*) debe ser la parte local completa.", "otpEmailSmtpRequired": "SMTP Requerido", "otpEmailSmtpRequiredDescription": "SMTP debe estar habilitado en el servidor para usar autenticación de contraseña de una sola vez.", "otpEmailTitle": "Contraseñas de una sola vez", "otpEmailTitleDescription": "Requiere autenticación por correo electrónico para acceso a recursos", "otpEmailWhitelist": "Lista blanca de correo", "otpEmailWhitelistList": "Correos en la lista blanca", "otpEmailWhitelistListDescription": "Sólo los usuarios con estas direcciones de correo electrónico podrán acceder a este recurso. Se les pedirá que introduzcan una contraseña de una sola vez enviada a su correo electrónico. Los comodines (*@ejemplo.com) pueden utilizarse para permitir cualquier dirección de correo electrónico de un dominio.", "otpEmailWhitelistSave": "Guardar lista blanca", "passwordAdd": "Añadir contraseña", "passwordRemove": "Eliminar contraseña", "pincodeAdd": "Añadir código PIN", "pincodeRemove": "Eliminar código PIN", "resourceAuthMethods": "Métodos de autenticación", "resourceAuthMethodsDescriptions": "Permitir el acceso al recurso a través de métodos de autenticación adicionales", "resourceAuthSettingsSave": "Guardado correctamente", "resourceAuthSettingsSaveDescription": "Se han guardado los ajustes de autenticación", "resourceErrorAuthFetch": "Error al recuperar datos", "resourceErrorAuthFetchDescription": "Se ha producido un error al recuperar los datos", "resourceErrorPasswordRemove": "Error al eliminar la contraseña del recurso", "resourceErrorPasswordRemoveDescription": "Se ha producido un error al eliminar la contraseña del recurso", "resourceErrorPasswordSetup": "Error al establecer la contraseña del recurso", "resourceErrorPasswordSetupDescription": "Se ha producido un error al establecer la contraseña del recurso", "resourceErrorPincodeRemove": "Error al eliminar el código pin del recurso", "resourceErrorPincodeRemoveDescription": "Ocurrió un error mientras se eliminaba el código pin del recurso", "resourceErrorPincodeSetup": "Error al establecer el código PIN del recurso", "resourceErrorPincodeSetupDescription": "Se ha producido un error al establecer el código PIN del recurso", "resourceErrorUsersRolesSave": "Error al establecer roles", "resourceErrorUsersRolesSaveDescription": "Se ha producido un error al establecer los roles", "resourceErrorWhitelistSave": "Error al guardar la lista blanca", "resourceErrorWhitelistSaveDescription": "Ocurrió un error mientras se guardaba la lista blanca", "resourcePasswordSubmit": "Activar la protección de contraseña", "resourcePasswordProtection": "Protección de contraseña {status}", "resourcePasswordRemove": "Contraseña de recurso eliminada", "resourcePasswordRemoveDescription": "La contraseña del recurso se ha eliminado correctamente", "resourcePasswordSetup": "Contraseña de recurso establecida", "resourcePasswordSetupDescription": "La contraseña del recurso se ha establecido correctamente", "resourcePasswordSetupTitle": "Establecer contraseña", "resourcePasswordSetupTitleDescription": "Establecer una contraseña para proteger este recurso", "resourcePincode": "Código PIN", "resourcePincodeSubmit": "Activar protección de código PIN", "resourcePincodeProtection": "Protección del código PIN {status}", "resourcePincodeRemove": "Código del recurso eliminado", "resourcePincodeRemoveDescription": "La contraseña del recurso se ha eliminado correctamente", "resourcePincodeSetup": "Código PIN del recurso establecido", "resourcePincodeSetupDescription": "El código del recurso se ha establecido correctamente", "resourcePincodeSetupTitle": "Definir Pincode", "resourcePincodeSetupTitleDescription": "Establecer un pincode para proteger este recurso", "resourceRoleDescription": "Los administradores siempre pueden acceder a este recurso.", "resourceUsersRoles": "Controles de acceso", "resourceUsersRolesDescription": "Configurar qué usuarios y roles pueden visitar este recurso", "resourceUsersRolesSubmit": "Guardar controles de acceso", "resourceWhitelistSave": "Guardado correctamente", "resourceWhitelistSaveDescription": "Se han guardado los ajustes de la lista blanca", "ssoUse": "Usar Plataforma SSO", "ssoUseDescription": "Los usuarios existentes sólo tendrán que iniciar sesión una vez para todos los recursos que tengan esto habilitado.", "proxyErrorInvalidPort": "Número de puerto inválido", "subdomainErrorInvalid": "Subdominio inválido", "domainErrorFetch": "Error obteniendo dominios", "domainErrorFetchDescription": "Se ha producido un error al recuperar los dominios", "resourceErrorUpdate": "Error al actualizar el recurso", "resourceErrorUpdateDescription": "Se ha producido un error al actualizar el recurso", "resourceUpdated": "Recurso actualizado", "resourceUpdatedDescription": "El recurso se ha actualizado correctamente", "resourceErrorTransfer": "Error al transferir el recurso", "resourceErrorTransferDescription": "Se ha producido un error al transferir el recurso", "resourceTransferred": "Recurso transferido", "resourceTransferredDescription": "El recurso ha sido transferido con éxito", "resourceErrorToggle": "Error al cambiar el recurso", "resourceErrorToggleDescription": "Se ha producido un error al actualizar el recurso", "resourceVisibilityTitle": "Visibilidad", "resourceVisibilityTitleDescription": "Activar o desactivar completamente la visibilidad de los recursos", "resourceGeneral": "Configuración General", "resourceGeneralDescription": "Configurar la configuración general de este recurso", "resourceEnable": "Activar recurso", "resourceTransfer": "Transferir recursos", "resourceTransferDescription": "Transferir este recurso a un sitio diferente", "resourceTransferSubmit": "Transferir recursos", "siteDestination": "Sitio de destino", "searchSites": "Buscar sitios", "countries": "Países", "accessRoleCreate": "Crear rol", "accessRoleCreateDescription": "Crear un nuevo rol para agrupar usuarios y administrar sus permisos.", "accessRoleEdit": "Editar rol", "accessRoleEditDescription": "Editar información de rol.", "accessRoleCreateSubmit": "Crear rol", "accessRoleCreated": "Rol creado", "accessRoleCreatedDescription": "El rol se ha creado correctamente.", "accessRoleErrorCreate": "Error al crear el rol", "accessRoleErrorCreateDescription": "Se ha producido un error al crear el rol.", "accessRoleUpdateSubmit": "Actualizar rol", "accessRoleUpdated": "Rol actualizado", "accessRoleUpdatedDescription": "El rol se ha actualizado correctamente.", "accessApprovalUpdated": "Aprobación procesada", "accessApprovalApprovedDescription": "Establezca la decisión de Solicitud de Aprobación a aprobar.", "accessApprovalDeniedDescription": "Define la decisión de Solicitud de Aprobación a denegar.", "accessRoleErrorUpdate": "Error al actualizar el rol", "accessRoleErrorUpdateDescription": "Se ha producido un error al actualizar el rol.", "accessApprovalErrorUpdate": "Error al procesar la aprobación", "accessApprovalErrorUpdateDescription": "Se ha producido un error al procesar la aprobación.", "accessRoleErrorNewRequired": "Se requiere un nuevo rol", "accessRoleErrorRemove": "Error al eliminar el rol", "accessRoleErrorRemoveDescription": "Ocurrió un error mientras se eliminaba el rol.", "accessRoleName": "Nombre del Rol", "accessRoleQuestionRemove": "Estás a punto de eliminar el rol `{name}`. No puedes deshacer esta acción.", "accessRoleRemove": "Quitar rol", "accessRoleRemoveDescription": "Eliminar un rol de la organización", "accessRoleRemoveSubmit": "Quitar rol", "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", "licenseTierProfessional": "Licencia profesional", "licenseTierEnterprise": "Licencia Enterprise", "licenseTierPersonal": "Licencia personal", "licensed": "Licenciado", "yes": "Sí", "no": "Nu", "sitesAdditional": "Sitios adicionales", "licenseKeys": "Claves de licencia", "sitestCountDecrease": "Reducir el número de sitios", "sitestCountIncrease": "Aumentar el número de sitios", "idpManage": "Administrar proveedores de identidad", "idpManageDescription": "Ver y administrar proveedores de identidad en el sistema", "idpGlobalModeBanner": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando IdPs globales (compartidos entre todas las organizaciones). Administra los IdPs globales en el panel de administración. Para habilitar los IdPs por organización, edita la configuración del servidor y establece el modo de IdP en org. Consulta la documentación. Si deseas seguir utilizando IdPs globales y hacer que esto desaparezca de las configuraciones de la organización, establece explícitamente el modo en global en la configuración.", "idpGlobalModeBannerUpgradeRequired": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando IdPs globales (compartidos entre todas las organizaciones). Administra los IdPs globales en el panel de administración. Para usar proveedores de identidad por organización, debes actualizar a la edición Empresarial.", "idpGlobalModeBannerLicenseRequired": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando identificadores globales (compartidos en todas las organizaciones). Gestionar identificaciones globales en el panel de administración. Para utilizar proveedores de identidad por organización, se requiere una licencia de empresa.", "idpDeletedDescription": "Proveedor de identidad eliminado correctamente", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "¿Está seguro que desea eliminar permanentemente el proveedor de identidad?", "idpMessageRemove": "Esto eliminará el proveedor de identidad y todas las configuraciones asociadas. Los usuarios que se autentifiquen a través de este proveedor ya no podrán iniciar sesión.", "idpMessageConfirm": "Para confirmar, por favor escriba el nombre del proveedor de identidad a continuación.", "idpConfirmDelete": "Confirmar eliminar proveedor de identidad", "idpDelete": "Eliminar proveedor de identidad", "idp": "Proveedores de identidad", "idpSearch": "Buscar proveedores de identidad...", "idpAdd": "Añadir proveedor de identidad", "idpClientIdRequired": "Se requiere ID de cliente.", "idpClientSecretRequired": "El secreto del cliente es obligatorio.", "idpErrorAuthUrlInvalid": "La URL de autenticación debe ser una URL válida.", "idpErrorTokenUrlInvalid": "La URL del token debe ser una URL válida.", "idpPathRequired": "La ruta identificadora es requerida.", "idpScopeRequired": "Se requiere alcance.", "idpOidcDescription": "Configurar un proveedor de identidad OpenID Connect", "idpCreatedDescription": "Proveedor de identidad creado correctamente", "idpCreate": "Crear proveedor de identidad", "idpCreateDescription": "Configurar un nuevo proveedor de identidad para la autenticación de usuario", "idpSeeAll": "Ver todos los proveedores de identidad", "idpSettingsDescription": "Configure la información básica para su proveedor de identidad", "idpDisplayName": "Un nombre mostrado para este proveedor de identidad", "idpAutoProvisionUsers": "Auto-Provisión de Usuarios", "idpAutoProvisionUsersDescription": "Cuando está habilitado, los usuarios serán creados automáticamente en el sistema al iniciar sesión con la capacidad de asignar a los usuarios a roles y organizaciones.", "licenseBadge": "EE", "idpType": "Tipo de proveedor", "idpTypeDescription": "Seleccione el tipo de proveedor de identidad que desea configurar", "idpOidcConfigure": "Configuración OAuth2/OIDC", "idpOidcConfigureDescription": "Configurar los puntos finales y credenciales del proveedor OAuth2/OIDC", "idpClientId": "ID de cliente", "idpClientIdDescription": "El ID del cliente OAuth2 del proveedor de identidad", "idpClientSecret": "Cliente secreto", "idpClientSecretDescription": "El secreto del cliente OAuth2 del proveedor de identidad", "idpAuthUrl": "URL de autorización", "idpAuthUrlDescription": "La URL final de autorización de OAuth2", "idpTokenUrl": "URL del token", "idpTokenUrlDescription": "La URL del endpoint del token OAuth2", "idpOidcConfigureAlert": "Información importante", "idpOidcConfigureAlertDescription": "Después de crear el proveedor de identidad, necesitará configurar la URL de callback en la configuración del proveedor de identidad. La URL de devolución de llamada se proporcionará después de la creación exitosa.", "idpToken": "Configuración del token", "idpTokenDescription": "Configurar cómo extraer la información del usuario del token de ID", "idpJmespathAbout": "Acerca de JMESPath", "idpJmespathAboutDescription": "Las siguientes rutas utilizan la sintaxis JMESPath para extraer valores del token ID.", "idpJmespathAboutDescriptionLink": "Más información sobre JMESPath", "idpJmespathLabel": "Ruta del identificador", "idpJmespathLabelDescription": "La ruta al identificador de usuario en el token de ID", "idpJmespathEmailPathOptional": "Ruta de correo (opcional)", "idpJmespathEmailPathOptionalDescription": "La ruta al correo electrónico del usuario en el token de ID", "idpJmespathNamePathOptional": "Ruta del nombre (opcional)", "idpJmespathNamePathOptionalDescription": "La ruta al nombre del usuario en el token de ID", "idpOidcConfigureScopes": "Ámbitos", "idpOidcConfigureScopesDescription": "Lista separada por espacios de los ámbitos OAuth2 a solicitar", "idpSubmit": "Crear proveedor de identidad", "orgPolicies": "Políticas de organización", "idpSettings": "Ajustes {idpName}", "idpCreateSettingsDescription": "Configurar la configuración del proveedor de identidad", "roleMapping": "Mapeo de Rol", "orgMapping": "Mapeo de organización", "orgPoliciesSearch": "Buscar políticas de organización...", "orgPoliciesAdd": "Añadir Política de Organización", "orgRequired": "La organización es obligatoria", "error": "Error", "success": "Éxito", "orgPolicyAddedDescription": "Política añadida correctamente", "orgPolicyUpdatedDescription": "Política actualizada correctamente", "orgPolicyDeletedDescription": "Política eliminada correctamente", "defaultMappingsUpdatedDescription": "Mapeos por defecto actualizados correctamente", "orgPoliciesAbout": "Acerca de políticas de organización", "orgPoliciesAboutDescription": "Las políticas de la organización se utilizan para controlar el acceso a las organizaciones basándose en el token de identificación del usuario. Puede especificar expresiones JMESPath para extraer información de rol y organización del token de identificación.", "orgPoliciesAboutDescriptionLink": "Vea la documentación, para más información.", "defaultMappingsOptional": "Mapeo por defecto (opcional)", "defaultMappingsOptionalDescription": "Los mapeos por defecto se utilizan cuando no hay una política de organización definida para una organización. Puede especificar las asignaciones predeterminadas de rol y organización a las que volver aquí.", "defaultMappingsRole": "Mapeo de Rol por defecto", "defaultMappingsRoleDescription": "El resultado de esta expresión debe devolver el nombre del rol tal y como se define en la organización como una cadena.", "defaultMappingsOrg": "Mapeo de organización por defecto", "defaultMappingsOrgDescription": "Esta expresión debe devolver el ID de org o verdadero para que el usuario pueda acceder a la organización.", "defaultMappingsSubmit": "Guardar asignaciones por defecto", "orgPoliciesEdit": "Editar Política de Organización", "org": "Organización", "orgSelect": "Seleccionar organización", "orgSearch": "Buscar org", "orgNotFound": "No se encontró org.", "roleMappingPathOptional": "Ruta de Mapeo de Rol (opcional)", "orgMappingPathOptional": "Ruta de mapeo de organización (opcional)", "orgPolicyUpdate": "Actualizar política", "orgPolicyAdd": "Añadir Política", "orgPolicyConfig": "Configurar acceso para una organización", "idpUpdatedDescription": "Proveedor de identidad actualizado correctamente", "redirectUrl": "URL de redirección", "orgIdpRedirectUrls": "Redirigir URL", "redirectUrlAbout": "Acerca de la URL de redirección", "redirectUrlAboutDescription": "Esta es la URL a la que los usuarios serán redireccionados después de la autenticación. Necesitas configurar esta URL en la configuración del proveedor de identidad.", "pangolinAuth": "Autenticación - Pangolin", "verificationCodeLengthRequirements": "Tu código de verificación debe tener 8 caracteres.", "errorOccurred": "Se ha producido un error", "emailErrorVerify": "No se pudo verificar el email:", "emailVerified": "¡Correo electrónico verificado con éxito! Redirigiendo...", "verificationCodeErrorResend": "Error al reenviar el código de verificación:", "verificationCodeResend": "Código de verificación reenviado", "verificationCodeResendDescription": "Hemos reenviado un código de verificación a tu dirección de correo electrónico. Por favor, comprueba tu bandeja de entrada.", "emailVerify": "Verificar Email", "emailVerifyDescription": "Introduzca el código de verificación enviado a su dirección de correo electrónico.", "verificationCode": "Código de verificación", "verificationCodeEmailSent": "Hemos enviado un código de verificación a tu dirección de correo electrónico.", "submit": "Enviar", "emailVerifyResendProgress": "Reenviando...", "emailVerifyResend": "¿No has recibido un código? Haz clic aquí para reenviar", "passwordNotMatch": "Las contraseñas no coinciden", "signupError": "Se ha producido un error al registrarse", "pangolinLogoAlt": "Logo de Pangolin", "inviteAlready": "¡Parece que has sido invitado!", "inviteAlreadyDescription": "Para aceptar la invitación, debes iniciar sesión o crear una cuenta.", "signupQuestion": "¿Ya tienes una cuenta?", "login": "Iniciar sesión", "resourceNotFound": "Recurso no encontrado", "resourceNotFoundDescription": "El recurso al que intentas acceder no existe.", "pincodeRequirementsLength": "El PIN debe tener exactamente 6 dígitos", "pincodeRequirementsChars": "El PIN sólo debe contener números", "passwordRequirementsLength": "La contraseña debe tener al menos 1 carácter", "passwordRequirementsTitle": "Requisitos de la contraseña:", "passwordRequirementLength": "Al menos 8 caracteres de largo", "passwordRequirementUppercase": "Al menos una letra mayúscula", "passwordRequirementLowercase": "Al menos una letra minúscula", "passwordRequirementNumber": "Al menos un número", "passwordRequirementSpecial": "Al menos un carácter especial", "passwordRequirementsMet": "✓ La contraseña cumple con todos los requisitos", "passwordStrength": "Seguridad de la contraseña", "passwordStrengthWeak": "Débil", "passwordStrengthMedium": "Media", "passwordStrengthStrong": "Fuerte", "passwordRequirements": "Requisitos:", "passwordRequirementLengthText": "8+ caracteres", "passwordRequirementUppercaseText": "Letra mayúscula (A-Z)", "passwordRequirementLowercaseText": "Letra minúscula (a-z)", "passwordRequirementNumberText": "Número (0-9)", "passwordRequirementSpecialText": "Caracter especial (!@#$%...)", "passwordsDoNotMatch": "Las contraseñas no coinciden", "otpEmailRequirementsLength": "OTP debe tener al menos 1 carácter", "otpEmailSent": "OTP enviado", "otpEmailSentDescription": "Un OTP ha sido enviado a tu correo electrónico", "otpEmailErrorAuthenticate": "Error al autenticar con el correo electrónico", "pincodeErrorAuthenticate": "Error al autenticar con pincode", "passwordErrorAuthenticate": "Error al autenticar con contraseña", "poweredBy": "Desarrollado por", "authenticationRequired": "Autenticación requerida", "authenticationMethodChoose": "Elige tu método preferido para acceder a {name}", "authenticationRequest": "Debes autenticarte para acceder a {name}", "user": "Usuario", "pincodeInput": "Código PIN de 6 dígitos", "pincodeSubmit": "Iniciar sesión con PIN", "passwordSubmit": "Iniciar sesión con contraseña", "otpEmailDescription": "Se enviará un código único a este correo electrónico.", "otpEmailSend": "Enviar código de una sola vez", "otpEmail": "Contraseña de una sola vez (OTP)", "otpEmailSubmit": "Enviar OTP", "backToEmail": "Volver al Email", "noSupportKey": "El servidor se está ejecutando sin una clave de soporte. ¡Considere apoyar el proyecto!", "accessDenied": "Acceso denegado", "accessDeniedDescription": "No tienes permiso para acceder a este recurso. Si esto es un error, por favor contacta con el administrador.", "accessTokenError": "Error comprobando el token de acceso", "accessGranted": "Acceso concedido", "accessUrlInvalid": "URL de acceso inválida", "accessGrantedDescription": "Se te ha concedido acceso a este recurso. Redirigiendo...", "accessUrlInvalidDescription": "Esta URL de acceso compartido no es válida. Por favor, póngase en contacto con el propietario del recurso para una nueva URL.", "tokenInvalid": "Token inválido", "pincodeInvalid": "Código inválido", "passwordErrorRequestReset": "Error al solicitar reinicio:", "passwordErrorReset": "Error al restablecer la contraseña:", "passwordResetSuccess": "¡Contraseña restablecida! Volver para iniciar sesión...", "passwordReset": "Restablecer contraseña", "passwordResetDescription": "Siga los pasos para restablecer su contraseña", "passwordResetSent": "Enviaremos un código para restablecer la contraseña a esta dirección de correo electrónico.", "passwordResetCode": "Código de restablecimiento", "passwordResetCodeDescription": "Revisa tu correo electrónico para ver el código de restablecimiento.", "generatePasswordResetCode": "Generar código de restablecimiento de contraseña", "passwordResetCodeGenerated": "Código de restablecimiento de contraseña generado", "passwordResetCodeGeneratedDescription": "Comparte este código con el usuario. Pueden usarlo para restablecer su contraseña.", "passwordResetUrl": "Reset URL", "passwordNew": "Nueva contraseña", "passwordNewConfirm": "Confirmar nueva contraseña", "changePassword": "Cambiar Contraseña", "changePasswordDescription": "Actualizar la contraseña de tu cuenta", "oldPassword": "Contraseña Actual", "newPassword": "Nueva Contraseña", "confirmNewPassword": "Confirme Nueva Contraseña", "changePasswordError": "Error al cambiar la contraseña", "changePasswordErrorDescription": "Se ha producido un error al cambiar la contraseña", "changePasswordSuccess": "La contraseña ha sido cambiada correctamente", "changePasswordSuccessDescription": "Su contraseña ha sido actualizada correctamente", "passwordExpiryRequired": "Contraseña con caducidad requerida", "passwordExpiryDescription": "Esta organización requiere que cambies tu contraseña cada {maxDays} días.", "changePasswordNow": "Cambiar Contraseña Ahora", "pincodeAuth": "Código de autenticación", "pincodeSubmit2": "Enviar código", "passwordResetSubmit": "Reiniciar Solicitud", "passwordResetAlreadyHaveCode": "Ingresar código", "passwordResetSmtpRequired": "Póngase en contacto con su administrador", "passwordResetSmtpRequiredDescription": "Se requiere un código de restablecimiento de contraseña para restablecer su contraseña. Póngase en contacto con su administrador para obtener asistencia.", "passwordBack": "Volver a la contraseña", "loginBack": "Volver a la página principal de acceso", "signup": "Regístrate", "loginStart": "Inicia sesión para empezar", "idpOidcTokenValidating": "Validando token OIDC", "idpOidcTokenResponse": "Validar respuesta de token OIDC", "idpErrorOidcTokenValidating": "Error al validar token OIDC", "idpConnectingTo": "Conectando a {name}", "idpConnectingToDescription": "Validando tu identidad", "idpConnectingToProcess": "Conectando...", "idpConnectingToFinished": "Conectado", "idpErrorConnectingTo": "Hubo un problema al conectar con {name}. Por favor, póngase en contacto con su administrador.", "idpErrorNotFound": "IdP no encontrado", "inviteInvalid": "Invitación inválida", "inviteInvalidDescription": "El enlace de invitación no es válido.", "inviteErrorWrongUser": "La invitación no es para este usuario", "inviteErrorUserNotExists": "El usuario no existe. Por favor, cree una cuenta primero.", "inviteErrorLoginRequired": "Debes estar conectado para aceptar una invitación", "inviteErrorExpired": "La invitación puede haber caducado", "inviteErrorRevoked": "La invitación podría haber sido revocada", "inviteErrorTypo": "Puede haber un error en el enlace de invitación", "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.", "overview": "Resumen", "home": "Inicio", "settings": "Ajustes", "usersAll": "Todos los usuarios", "license": "Licencia", "pangolinDashboard": "Tablero - Pangolin", "noResults": "No se han encontrado resultados.", "terabytes": "TB {count}", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "Etiquetas introducidas", "tagsEnteredDescription": "Estas son las etiquetas que has introducido.", "tagsWarnCannotBeLessThanZero": "maxTags y minTags no pueden ser menores que 0", "tagsWarnNotAllowedAutocompleteOptions": "Etiqueta no permitida como opciones de autocompletado", "tagsWarnInvalid": "Etiqueta no válida según validateTag", "tagWarnTooShort": "La etiqueta {tagText} es demasiado corta", "tagWarnTooLong": "La etiqueta {tagText} es demasiado larga", "tagsWarnReachedMaxNumber": "Alcanzado el número máximo de etiquetas permitidas", "tagWarnDuplicate": "Etiqueta {tagText} duplicada no añadida", "supportKeyInvalid": "Clave inválida", "supportKeyInvalidDescription": "Tu clave de seguidor no es válida.", "supportKeyValid": "Clave válida", "supportKeyValidDescription": "Su clave de seguidor ha sido validada. ¡Gracias por su apoyo!", "supportKeyErrorValidationDescription": "Error al validar la clave de seguidor.", "supportKey": "¡Apoya el Desarrollo y Adopte un Pangolin!", "supportKeyDescription": "Compra una clave de seguidor para ayudarnos a seguir desarrollando Pangolin para la comunidad. Su contribución nos permite comprometer más tiempo para mantener y añadir nuevas características a la aplicación para todos. Nunca usaremos esto para las características de paywall. Esto está separado de cualquier Edición Comercial.", "supportKeyPet": "También podrás adoptar y conocer a tu propio Pangolin mascota.", "supportKeyPurchase": "Los pagos se procesan a través de GitHub. Después, puede recuperar su clave en", "supportKeyPurchaseLink": "nuestro sitio web", "supportKeyPurchase2": "y canjéelo aquí.", "supportKeyLearnMore": "Más información.", "supportKeyOptions": "Por favor, seleccione la opción que más le convenga.", "supportKetOptionFull": "Asistente completo", "forWholeServer": "Para todo el servidor", "lifetimePurchase": "Compra de por vida", "supporterStatus": "Estado del soporte", "buy": "Comprar", "supportKeyOptionLimited": "Apoyador limitado", "forFiveUsers": "Para 5 o menos usuarios", "supportKeyRedeem": "Canjear Clave de Apoyo", "supportKeyHideSevenDays": "Ocultar durante 7 días", "supportKeyEnter": "Introduzca Clave de Soporter", "supportKeyEnterDescription": "Conoce a tu propia mascota Pangolin!", "githubUsername": "Nombre de usuario de GitHub", "supportKeyInput": "Clave de apoyo", "supportKeyBuy": "Comprar Clave de Apoyo", "logoutError": "Error al cerrar sesión", "signingAs": "Conectado como", "serverAdmin": "Admin Servidor", "managedSelfhosted": "Autogestionado", "otpEnable": "Activar doble factor", "otpDisable": "Desactivar doble factor", "logout": "Cerrar sesión", "licenseTierProfessionalRequired": "Edición Profesional requerida", "licenseTierProfessionalRequiredDescription": "Esta característica sólo está disponible en la Edición Profesional.", "actionGetOrg": "Obtener organización", "updateOrgUser": "Actualizar usuario Org", "createOrgUser": "Crear usuario Org", "actionUpdateOrg": "Actualizar organización", "actionRemoveInvitation": "Eliminar invitación", "actionUpdateUser": "Actualizar usuario", "actionGetUser": "Obtener usuario", "actionGetOrgUser": "Obtener usuario de la organización", "actionListOrgDomains": "Listar dominios de la organización", "actionGetDomain": "Obtener dominio", "actionCreateOrgDomain": "Crear dominio", "actionUpdateOrgDomain": "Actualizar dominio", "actionDeleteOrgDomain": "Eliminar dominio", "actionGetDNSRecords": "Obtener registros DNS", "actionRestartOrgDomain": "Reiniciar dominio", "actionCreateSite": "Crear sitio", "actionDeleteSite": "Eliminar sitio", "actionGetSite": "Obtener sitio", "actionListSites": "Listar sitios", "actionApplyBlueprint": "Aplicar plano", "actionListBlueprints": "Listar blueprints", "actionGetBlueprint": "Obtener blueprint", "setupToken": "Configuración de token", "setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.", "setupTokenRequired": "Se requiere el token de configuración", "actionUpdateSite": "Actualizar sitio", "actionListSiteRoles": "Lista de roles permitidos del sitio", "actionCreateResource": "Crear Recurso", "actionDeleteResource": "Eliminar Recurso", "actionGetResource": "Obtener recursos", "actionListResource": "Listar recursos", "actionUpdateResource": "Actualizar Recurso", "actionListResourceUsers": "Listar usuarios de recursos", "actionSetResourceUsers": "Establecer usuarios de recursos", "actionSetAllowedResourceRoles": "Establecer roles de recursos permitidos", "actionListAllowedResourceRoles": "Lista de roles de recursos permitidos", "actionSetResourcePassword": "Establecer contraseña de recurso", "actionSetResourcePincode": "Establecer Pincode del recurso", "actionSetResourceEmailWhitelist": "Establecer lista blanca de correo de recursos", "actionGetResourceEmailWhitelist": "Obtener correo electrónico de recursos", "actionCreateTarget": "Crear destino", "actionDeleteTarget": "Eliminar destino", "actionGetTarget": "Obtener objetivo", "actionListTargets": "Lista de objetivos", "actionUpdateTarget": "Actualizar destino", "actionCreateRole": "Crear rol", "actionDeleteRole": "Eliminar rol", "actionGetRole": "Obtener rol", "actionListRole": "Lista de roles", "actionUpdateRole": "Actualizar rol", "actionListAllowedRoleResources": "Lista de recursos de rol permitidos", "actionInviteUser": "Invitar usuario", "actionRemoveUser": "Eliminar usuario", "actionListUsers": "Listar usuarios", "actionAddUserRole": "Añadir rol de usuario", "actionGenerateAccessToken": "Generar token de acceso", "actionDeleteAccessToken": "Eliminar token de acceso", "actionListAccessTokens": "Lista de Tokens de Acceso", "actionCreateResourceRule": "Crear Regla de Recursos", "actionDeleteResourceRule": "Eliminar Regla de Recurso", "actionListResourceRules": "Lista de Reglas de Recursos", "actionUpdateResourceRule": "Actualizar regla de recursos", "actionListOrgs": "Listar organizaciones", "actionCheckOrgId": "Comprobar ID", "actionCreateOrg": "Crear organización", "actionDeleteOrg": "Eliminar organización", "actionListApiKeys": "Lista de claves API", "actionListApiKeyActions": "Listar acciones clave API", "actionSetApiKeyActions": "Establecer acciones de clave API permitidas", "actionCreateApiKey": "Crear Clave API", "actionDeleteApiKey": "Borrar Clave API", "actionCreateIdp": "Crear IDP", "actionUpdateIdp": "Actualizar IDP", "actionDeleteIdp": "Eliminar IDP", "actionListIdps": "Listar IDP", "actionGetIdp": "Obtener IDP", "actionCreateIdpOrg": "Crear política de IDP Org", "actionDeleteIdpOrg": "Eliminar política de IDP Org", "actionListIdpOrgs": "Listar Orgs IDP", "actionUpdateIdpOrg": "Actualizar IDP Org", "actionCreateClient": "Crear cliente", "actionDeleteClient": "Eliminar cliente", "actionArchiveClient": "Archivar cliente", "actionUnarchiveClient": "Desarchivar cliente", "actionBlockClient": "Bloquear cliente", "actionUnblockClient": "Desbloquear cliente", "actionUpdateClient": "Actualizar cliente", "actionListClients": "Listar clientes", "actionGetClient": "Obtener cliente", "actionCreateSiteResource": "Crear Recurso del Sitio", "actionDeleteSiteResource": "Eliminar recurso del sitio", "actionGetSiteResource": "Obtener recurso del sitio", "actionListSiteResources": "Listar recursos del sitio", "actionUpdateSiteResource": "Actualizar recurso del sitio", "actionListInvitations": "Listar invitaciones", "actionExportLogs": "Exportar registros", "actionViewLogs": "Ver registros", "noneSelected": "Ninguno seleccionado", "orgNotFound2": "No se encontraron organizaciones.", "searchPlaceholder": "Buscar...", "emptySearchOptions": "No se encontraron opciones", "create": "Crear", "orgs": "Organizaciones", "loginError": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo.", "loginRequiredForDevice": "Es necesario iniciar sesión para tu dispositivo.", "passwordForgot": "¿Olvidaste tu contraseña?", "otpAuth": "Autenticación de dos factores", "otpAuthDescription": "Introduzca el código de su aplicación de autenticación o uno de sus códigos de copia de seguridad de un solo uso.", "otpAuthSubmit": "Enviar código", "idpContinue": "O continuar con", "otpAuthBack": "Volver a la contraseña", "navbar": "Menú de navegación", "navbarDescription": "Menú de navegación principal para la aplicación", "navbarDocsLink": "Documentación", "otpErrorEnable": "No se puede habilitar 2FA", "otpErrorEnableDescription": "Se ha producido un error al habilitar 2FA", "otpSetupCheckCode": "Por favor, introduzca un código de 6 dígitos", "otpSetupCheckCodeRetry": "Código no válido. Vuelve a intentarlo.", "otpSetup": "Habilitar autenticación de doble factor", "otpSetupDescription": "Asegure su cuenta con una capa extra de protección", "otpSetupScanQr": "Escanea este código QR con tu aplicación de autenticación o introduce la clave secreta manualmente:", "otpSetupSecretCode": "Código de autenticación", "otpSetupSuccess": "Autenticación de dos factores habilitada", "otpSetupSuccessStoreBackupCodes": "Tu cuenta ahora es más segura. No olvides guardar tus códigos de respaldo.", "otpErrorDisable": "No se puede desactivar 2FA", "otpErrorDisableDescription": "Se ha producido un error al desactivar 2FA", "otpRemove": "Desactivar autenticación de doble factor", "otpRemoveDescription": "Desactivar autenticación de doble factor para su cuenta", "otpRemoveSuccess": "Autenticación de dos factores desactivada", "otpRemoveSuccessMessage": "La autenticación de doble factor ha sido deshabilitada para su cuenta. Puede activarla de nuevo en cualquier momento.", "otpRemoveSubmit": "Desactivar 2FA", "paginator": "Página {current} de {last}", "paginatorToFirst": "Ir a la primera página", "paginatorToPrevious": "Ir a la página anterior", "paginatorToNext": "Ir a la página siguiente", "paginatorToLast": "Ir a la última página", "copyText": "Copiar texto", "copyTextFailed": "Error al copiar texto: ", "copyTextClipboard": "Copiar al portapapeles", "inviteErrorInvalidConfirmation": "Confirmación no válida", "passwordRequired": "Se requiere contraseña", "allowAll": "Permitir todo", "permissionsAllowAll": "Permitir todos los permisos", "githubUsernameRequired": "Se requiere el nombre de usuario de GitHub", "supportKeyRequired": "Clave de apoyo es requerida", "passwordRequirementsChars": "La contraseña debe tener al menos 8 caracteres", "language": "Idioma", "verificationCodeRequired": "El código es requerido", "userErrorNoUpdate": "Ningún usuario para actualizar", "siteErrorNoUpdate": "No hay sitio para actualizar", "resourceErrorNoUpdate": "Ningún recurso para actualizar", "authErrorNoUpdate": "No hay información de autenticación para actualizar", "orgErrorNoUpdate": "No hay org para actualizar", "orgErrorNoProvided": "No hay org proporcionado", "apiKeysErrorNoUpdate": "Ninguna clave API para actualizar", "sidebarOverview": "Resumen", "sidebarHome": "Inicio", "sidebarSites": "Sitios", "sidebarApprovals": "Solicitudes de aprobación", "sidebarResources": "Recursos", "sidebarProxyResources": "Público", "sidebarClientResources": "Privado", "sidebarAccessControl": "Control de acceso", "sidebarLogsAndAnalytics": "Registros y análisis", "sidebarTeam": "Equipo", "sidebarUsers": "Usuarios", "sidebarAdmin": "Admin", "sidebarInvitations": "Invitaciones", "sidebarRoles": "Roles", "sidebarShareableLinks": "Enlaces", "sidebarApiKeys": "Claves API", "sidebarSettings": "Ajustes", "sidebarAllUsers": "Todos los usuarios", "sidebarIdentityProviders": "Proveedores de identidad", "sidebarLicense": "Licencia", "sidebarClients": "Clientes", "sidebarUserDevices": "Dispositivos de usuario", "sidebarMachineClients": "Máquinas", "sidebarDomains": "Dominios", "sidebarGeneral": "Gestionar", "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", "blueprintAdd": "Añadir plano", "blueprintGoBack": "Ver todos los Planos", "blueprintCreate": "Crear Plano", "blueprintCreateDescription2": "Siga los siguientes pasos para crear y aplicar un nuevo plano", "blueprintDetails": "Detalles del plano", "blueprintDetailsDescription": "Ver el resultado del plano aplicado y cualquier error que haya ocurrido", "blueprintInfo": "Información del plano", "message": "Mensaje", "blueprintContentsDescription": "Definir el contenido YAML describiendo la infraestructura", "blueprintErrorCreateDescription": "Se ha producido un error al aplicar el plano", "blueprintErrorCreate": "Error al crear el plano", "searchBlueprintProgress": "Buscar planos...", "appliedAt": "Aplicado en", "source": "Fuente", "contents": "Contenido", "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.", "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.", "containerName": "Nombre", "containerImage": "Imagen", "containerState": "Estado", "containerNetworks": "Redes", "containerHostnameIp": "Nombre del host/IP", "containerLabels": "Etiquetas", "containerLabelsCount": "{count, plural, one {# etiqueta} other {# etiquetas}}", "containerLabelsTitle": "Etiquetas de contenedor", "containerLabelEmpty": "", "containerPorts": "Puertos", "containerPortsMore": "+{count} más", "containerActions": "Acciones", "select": "Seleccionar", "noContainersMatchingFilters": "No se encontraron contenedores que coincidan con los filtros actuales.", "showContainersWithoutPorts": "Mostrar contenedores sin puertos", "showStoppedContainers": "Mostrar contenedores parados", "noContainersFound": "No se han encontrado contenedores. Asegúrate de que los contenedores Docker se estén ejecutando.", "searchContainersPlaceholder": "Buscar a través de contenedores {count}...", "searchResultsCount": "{count, plural, one {# resultado} other {# resultados}}", "filters": "Filtros", "filterOptions": "Opciones de filtro", "filterPorts": "Puertos", "filterStopped": "Detenido", "clearAllFilters": "Borrar todos los filtros", "columns": "Columnas", "toggleColumns": "Cambiar Columnas", "refreshContainersList": "Actualizar lista de contenedores", "searching": "Buscando...", "noContainersFoundMatching": "No se han encontrado contenedores que coincidan con \"{filter}\".", "light": "claro", "dark": "oscuro", "system": "sistema", "theme": "Tema", "subnetRequired": "Se requiere subred", "initialSetupTitle": "Configuración inicial del servidor", "initialSetupDescription": "Cree la cuenta de administrador del servidor inicial. Solo puede existir un administrador del servidor. Siempre puede cambiar estas credenciales más tarde.", "createAdminAccount": "Crear cuenta de administrador", "setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.", "certificateStatus": "Estado del certificado", "loading": "Cargando", "loadingAnalytics": "Cargando analíticas", "restart": "Reiniciar", "domains": "Dominios", "domainsDescription": "Crear y administrar dominios disponibles en la organización", "domainsSearch": "Buscar dominios...", "domainAdd": "Agregar dominio", "domainAddDescription": "Registrar un nuevo dominio con la organización", "domainCreate": "Crear dominio", "domainCreatedDescription": "Dominio creado con éxito", "domainDeletedDescription": "Dominio eliminado exitosamente", "domainQuestionRemove": "¿Está seguro que desea eliminar el dominio?", "domainMessageRemove": "Una vez eliminado, el dominio ya no estará asociado a la organización.", "domainConfirmDelete": "Confirmar eliminación del dominio", "domainDelete": "Eliminar dominio", "domain": "Dominio", "selectDomainTypeNsName": "Delegación de dominio (NS)", "selectDomainTypeNsDescription": "Este dominio y todos sus subdominios. Usa esto cuando quieras controlar una zona de dominio completa.", "selectDomainTypeCnameName": "Dominio único (CNAME)", "selectDomainTypeCnameDescription": "Solo este dominio específico. Úsalo para subdominios individuales o entradas específicas de dominio.", "selectDomainTypeWildcardName": "Dominio comodín", "selectDomainTypeWildcardDescription": "Este dominio y sus subdominios.", "domainDelegation": "Dominio único", "selectType": "Selecciona un tipo", "actions": "Acciones", "refresh": "Actualizar", "refreshError": "Error al actualizar datos", "verified": "Verificado", "pending": "Pendiente", "pendingApproval": "Pendientes de aprobación", "sidebarBilling": "Facturación", "billing": "Facturación", "orgBillingDescription": "Administrar información de facturación y suscripciones", "github": "GitHub", "pangolinHosted": "Pangolin Alojado", "fossorial": "Fossorial", "completeAccountSetup": "Completar configuración de cuenta", "completeAccountSetupDescription": "Establece tu contraseña para comenzar", "accountSetupSent": "Enviaremos un código de configuración de cuenta a esta dirección de correo electrónico.", "accountSetupCode": "Código de configuración", "accountSetupCodeDescription": "Revisa tu correo para el código de configuración.", "passwordCreate": "Crear contraseña", "passwordCreateConfirm": "Confirmar contraseña", "accountSetupSubmit": "Enviar código de configuración", "completeSetup": "Completar configuración", "accountSetupSuccess": "¡Configuración de cuenta completada! ¡Bienvenido a Pangolin!", "documentation": "Documentación", "saveAllSettings": "Guardar todos los ajustes", "saveResourceTargets": "Guardar objetivos", "saveResourceHttp": "Guardar ajustes de proxy", "saveProxyProtocol": "Guardar configuraciones del protocolo de proxy", "settingsUpdated": "Ajustes actualizados", "settingsUpdatedDescription": "Configuraciones actualizadas correctamente", "settingsErrorUpdate": "Error al actualizar ajustes", "settingsErrorUpdateDescription": "Ocurrió un error al actualizar ajustes", "sidebarCollapse": "Colapsar", "sidebarExpand": "Expandir", "productUpdateMoreInfo": "{noOfUpdates} actualizaciones más", "productUpdateInfo": "{noOfUpdates} actualizaciones", "productUpdateWhatsNew": "Novedades", "productUpdateTitle": "Actualizaciones de producto", "productUpdateEmpty": "Sin actualizaciones", "dismissAll": "Descartar todo", "pangolinUpdateAvailable": "Actualización disponible", "pangolinUpdateAvailableInfo": "La versión {version} está lista para instalar", "pangolinUpdateAvailableReleaseNotes": "Ver notas de lanzamiento", "newtUpdateAvailable": "Nueva actualización disponible", "newtUpdateAvailableInfo": "Hay una nueva versión de Newt disponible. Actualice a la última versión para la mejor experiencia.", "domainPickerEnterDomain": "Dominio", "domainPickerPlaceholder": "miapp.ejemplo.com", "domainPickerDescription": "Ingresa el dominio completo del recurso para ver las opciones disponibles.", "domainPickerDescriptionSaas": "Ingresa un dominio completo, subdominio o simplemente un nombre para ver las opciones disponibles", "domainPickerTabAll": "Todo", "domainPickerTabOrganization": "Organización", "domainPickerTabProvided": "Proporcionado", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Comprobando disponibilidad...", "domainPickerNoMatchingDomains": "No se han encontrado dominios coincidentes. Prueba un dominio diferente o comprueba la configuración de dominio de la organización.", "domainPickerOrganizationDomains": "Dominios de la organización", "domainPickerProvidedDomains": "Dominios proporcionados", "domainPickerSubdomain": "Subdominio: {subdomain}", "domainPickerNamespace": "Espacio de nombres: {namespace}", "domainPickerShowMore": "Mostrar más", "regionSelectorTitle": "Seleccionar Región", "regionSelectorInfo": "Seleccionar una región nos ayuda a brindar un mejor rendimiento para tu ubicación. No tienes que estar en la misma región que tu servidor.", "regionSelectorPlaceholder": "Elige una región", "regionSelectorComingSoon": "Próximamente", "billingLoadingSubscription": "Cargando suscripción...", "billingFreeTier": "Nivel Gratis", "billingWarningOverLimit": "Advertencia: Has excedido uno o más límites de uso. Tus sitios no se conectarán hasta que modifiques tu suscripción o ajustes tu uso.", "billingUsageLimitsOverview": "Descripción general de los límites de uso", "billingMonitorUsage": "Monitorea tu uso comparado con los límites configurados. Si necesitas que aumenten los límites, contáctanos a soporte@pangolin.net.", "billingDataUsage": "Uso de datos", "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", "billingIncludedUsage": "Uso incluido", "billingIncludedUsageDescription": "Uso incluido con su plan de suscripción actual", "billingFreeTierIncludedUsage": "Permisos de uso del nivel gratuito", "billingIncluded": "incluido", "billingEstimatedTotal": "Total Estimado:", "billingNotes": "Notas", "billingEstimateNote": "Esta es una estimación basada en tu uso actual.", "billingActualChargesMayVary": "Los cargos reales pueden variar.", "billingBilledAtEnd": "Se te facturará al final del período de facturación.", "billingModifySubscription": "Modificar Suscripción", "billingStartSubscription": "Iniciar Suscripción", "billingRecurringCharge": "Cargo Recurrente", "billingManageSubscriptionSettings": "Administrar ajustes y preferencias de suscripción", "billingNoActiveSubscription": "No tienes una suscripción activa. Inicia tu suscripción para aumentar los límites de uso.", "billingFailedToLoadSubscription": "Error al cargar la suscripción", "billingFailedToLoadUsage": "Error al cargar el uso", "billingFailedToGetCheckoutUrl": "Error al obtener la URL de pago", "billingPleaseTryAgainLater": "Por favor, inténtelo de nuevo más tarde.", "billingCheckoutError": "Error de pago", "billingFailedToGetPortalUrl": "Error al obtener la URL del portal", "billingPortalError": "Error del portal", "billingDataUsageInfo": "Se le cobran todos los datos transferidos a través de sus túneles seguros cuando se conectan a la nube. Esto incluye tanto tráfico entrante como saliente a través de todos sus sitios. Cuando alcance su límite, sus sitios se desconectarán hasta que actualice su plan o reduzca el uso. Los datos no se cargan cuando se usan nodos.", "billingSInfo": "Cuántos sitios puedes usar", "billingUsersInfo": "Cuántos usuarios puedes usar", "billingDomainInfo": "Cuántos dominios puedes usar", "billingRemoteExitNodesInfo": "Cuántos nodos remotos puedes usar", "billingLicenseKeys": "Claves de licencia", "billingLicenseKeysDescription": "Administrar las suscripciones de su clave de licencia", "billingLicenseSubscription": "Suscripción de licencia", "billingInactive": "Inactivo", "billingLicenseItem": "Licencia", "billingQuantity": "Cantidad", "billingTotal": "total", "billingModifyLicenses": "Modificar suscripción de licencia", "domainNotFound": "Dominio no encontrado", "domainNotFoundDescription": "Este recurso está deshabilitado porque el dominio ya no existe en nuestro sistema. Por favor, establece un nuevo dominio para este recurso.", "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", "securityKeyRegister": "Registrar nueva llave de seguridad", "securityKeyList": "Tus llaves de seguridad", "securityKeyNone": "No hay llaves de seguridad registradas", "securityKeyNameRequired": "El nombre es requerido", "securityKeyRemove": "Eliminar", "securityKeyLastUsed": "Último uso: {date}", "securityKeyNameLabel": "Nombre", "securityKeyRegisterSuccess": "Llave de seguridad registrada exitosamente", "securityKeyRegisterError": "Error al registrar la llave de seguridad", "securityKeyRemoveSuccess": "Llave de seguridad eliminada exitosamente", "securityKeyRemoveError": "Error al eliminar la llave de seguridad", "securityKeyLoadError": "Error al cargar las llaves de seguridad", "securityKeyLogin": "Usar clave de seguridad", "securityKeyAuthError": "Error al autenticar con llave de seguridad", "securityKeyRecommendation": "Considere registrar otra llave de seguridad en un dispositivo diferente para asegurarse de no quedar bloqueado de su cuenta.", "registering": "Registrando...", "securityKeyPrompt": "Por favor, verifica tu identidad usando tu llave de seguridad. Asegúrate de que tu llave de seguridad esté conectada y lista.", "securityKeyBrowserNotSupported": "Tu navegador no admite llaves de seguridad. Por favor, usa un navegador moderno como Chrome, Firefox o Safari.", "securityKeyPermissionDenied": "Por favor, permite el acceso a tu llave de seguridad para continuar iniciando sesión.", "securityKeyRemovedTooQuickly": "Por favor, mantén tu llave de seguridad conectada hasta que el proceso de inicio de sesión se complete.", "securityKeyNotSupported": "Tu llave de seguridad puede no ser compatible. Por favor, prueba con una llave de seguridad diferente.", "securityKeyUnknownError": "Hubo un problema al usar tu llave de seguridad. Por favor, inténtalo de nuevo.", "twoFactorRequired": "Se requiere autenticación de dos factores para registrar una llave de seguridad.", "twoFactor": "Autenticación de dos factores", "twoFactorAuthentication": "Autenticación de Dos Factores", "twoFactorDescription": "Esta organización requiere autenticación de dos factores.", "enableTwoFactor": "Habilitar autenticación de dos factores", "organizationSecurityPolicy": "Política de Seguridad de la Organización", "organizationSecurityPolicyDescription": "Esta organización tiene requisitos de seguridad que deben cumplirse antes de poder acceder a ella", "securityRequirements": "Requisitos de seguridad", "allRequirementsMet": "Todos los requisitos han sido cumplidos", "completeRequirementsToContinue": "Completa los siguientes requisitos para seguir accediendo a esta organización", "youCanNowAccessOrganization": "Ahora puedes acceder a esta organización", "reauthenticationRequired": "Longitud de la sesión", "reauthenticationDescription": "Esta organización requiere que inicies sesión cada {maxDays} días.", "reauthenticationDescriptionHours": "Esta organización requiere que inicies sesión cada {maxHours} horas.", "reauthenticateNow": "Iniciar sesión de nuevo", "adminEnabled2FaOnYourAccount": "Su administrador ha habilitado la autenticación de dos factores para {email}. Por favor, complete el proceso de configuración para continuar.", "securityKeyAdd": "Agregar llave de seguridad", "securityKeyRegisterTitle": "Registrar nueva llave de seguridad", "securityKeyRegisterDescription": "Conecta tu llave de seguridad y escribe un nombre para identificarla", "securityKeyTwoFactorRequired": "Se requiere autenticación de dos factores", "securityKeyTwoFactorDescription": "Por favor, ingresa tu código de autenticación de dos factores para registrar la llave de seguridad", "securityKeyTwoFactorRemoveDescription": "Por favor, ingresa tu código de autenticación de dos factores para eliminar la llave de seguridad", "securityKeyTwoFactorCode": "Código de autenticación de dos factores", "securityKeyRemoveTitle": "Eliminar llave de seguridad", "securityKeyRemoveDescription": "Ingresa tu contraseña para eliminar la llave de seguridad \"{name}\"", "securityKeyNoKeysRegistered": "No hay llaves de seguridad registradas", "securityKeyNoKeysDescription": "Agrega una llave de seguridad para mejorar la seguridad de tu cuenta", "createDomainRequired": "Se requiere dominio", "createDomainAddDnsRecords": "Agregar registros DNS", "createDomainAddDnsRecordsDescription": "Agrega los siguientes registros DNS a tu proveedor de dominios para completar la configuración.", "createDomainNsRecords": "Registros NS", "createDomainRecord": "Registro", "createDomainType": "Tipo:", "createDomainName": "Nombre:", "createDomainValue": "Valor:", "createDomainCnameRecords": "Registros CNAME", "createDomainARecords": "Registros A", "createDomainRecordNumber": "Registro {number}", "createDomainTxtRecords": "Registros TXT", "createDomainSaveTheseRecords": "Guardar estos registros", "createDomainSaveTheseRecordsDescription": "Asegúrate de guardar estos registros DNS ya que no los verás de nuevo.", "createDomainDnsPropagation": "Propagación DNS", "createDomainDnsPropagationDescription": "Los cambios de DNS pueden tardar un tiempo en propagarse a través de internet. Esto puede tardar desde unos pocos minutos hasta 48 horas, dependiendo de tu proveedor de DNS y la configuración de TTL.", "resourcePortRequired": "Se requiere número de puerto para recursos no HTTP", "resourcePortNotAllowed": "El número de puerto no debe establecerse para recursos HTTP", "billingPricingCalculatorLink": "Calculadora de Precios", "billingYourPlan": "Su plan", "billingViewOrModifyPlan": "Ver o modificar su plan actual", "billingViewPlanDetails": "Ver detalles del plan", "billingUsageAndLimits": "Uso y límites", "billingViewUsageAndLimits": "Ver los límites de tu plan y el uso actual", "billingCurrentUsage": "Uso actual", "billingMaximumLimits": "Límites máximos", "billingRemoteNodes": "Nodos remotos", "billingUnlimited": "Ilimitado", "billingPaidLicenseKeys": "Claves de licencia pagadas", "billingManageLicenseSubscription": "Administra tu suscripción para las claves de licencia autoalojadas pagadas", "billingCurrentKeys": "Claves actuales", "billingModifyCurrentPlan": "Modificar plan actual", "billingConfirmUpgrade": "Confirmar actualización", "billingConfirmDowngrade": "Confirmar descenso", "billingConfirmUpgradeDescription": "Estás a punto de actualizar tu plan. Revisa los nuevos límites y precios a continuación.", "billingConfirmDowngradeDescription": "Está a punto de rebajar su plan. Revise los nuevos límites y los precios a continuación.", "billingPlanIncludes": "Plan Incluye", "billingProcessing": "Procesando...", "billingConfirmUpgradeButton": "Confirmar actualización", "billingConfirmDowngradeButton": "Confirmar descenso", "billingLimitViolationWarning": "El uso excede los nuevos límites del plan", "billingLimitViolationDescription": "Su uso actual excede los límites de este plan. Después de degradar, todas las acciones se desactivarán hasta que reduzca el uso dentro de los nuevos límites. Por favor, revisa las siguientes características que están actualmente por encima de los límites. Límites en violación:", "billingFeatureLossWarning": "Aviso de disponibilidad de funcionalidad", "billingFeatureLossDescription": "Al degradar, las características no disponibles en el nuevo plan se desactivarán automáticamente. Algunas configuraciones y configuraciones pueden perderse. Por favor, revise la matriz de precios para entender qué características ya no estarán disponibles.", "billingUsageExceedsLimit": "El uso actual ({current}) supera el límite ({limit})", "billingPastDueTitle": "Pago vencido", "billingPastDueDescription": "Su pago ha vencido. Por favor, actualice su método de pago para seguir utilizando las características actuales de su plan. Si no se resuelve, tu suscripción se cancelará y serás revertido al nivel gratuito.", "billingUnpaidTitle": "Suscripción no pagada", "billingUnpaidDescription": "Tu suscripción no está pagada y has sido revertido al nivel gratuito. Por favor, actualiza tu método de pago para restaurar tu suscripción.", "billingIncompleteTitle": "Pago incompleto", "billingIncompleteDescription": "Su pago está incompleto. Por favor, complete el proceso de pago para activar su suscripción.", "billingIncompleteExpiredTitle": "Pago expirado", "billingIncompleteExpiredDescription": "Tu pago nunca se completó y ha expirado. Has sido revertido al nivel gratuito. Suscríbete de nuevo para restaurar el acceso a las funciones de pago.", "billingManageSubscription": "Administra tu suscripción", "billingResolvePaymentIssue": "Por favor resuelva su problema de pago antes de actualizar o bajar de calificación", "signUpTerms": { "IAgreeToThe": "Estoy de acuerdo con los", "termsOfService": "términos del servicio", "and": "y", "privacyPolicy": "política de privacidad." }, "signUpMarketing": { "keepMeInTheLoop": "Mantenerme en el bucle con noticias, actualizaciones y nuevas características por correo electrónico." }, "siteRequired": "El sitio es requerido.", "olmTunnel": "Túnel Olm", "olmTunnelDescription": "Usar Olm para la conectividad del cliente", "errorCreatingClient": "Error al crear el cliente", "clientDefaultsNotFound": "Configuración predeterminada del cliente no encontrada", "createClient": "Crear cliente", "createClientDescription": "Crear un nuevo cliente para acceder a recursos privados", "seeAllClients": "Ver todos los clientes", "clientInformation": "Información del cliente", "clientNamePlaceholder": "Nombre del cliente", "address": "Dirección", "subnetPlaceholder": "Subred", "addressDescription": "La dirección interna del cliente. Debe estar dentro de la subred de la organización.", "selectSites": "Seleccionar sitios", "sitesDescription": "El cliente tendrá conectividad con los sitios seleccionados", "clientInstallOlm": "Instalar Olm", "clientInstallOlmDescription": "Obtén Olm funcionando en tu sistema", "clientOlmCredentials": "Credenciales", "clientOlmCredentialsDescription": "Así es como el cliente se autentificará con el servidor", "olmEndpoint": "Endpoint", "olmId": "ID", "olmSecretKey": "Secreto", "clientCredentialsSave": "Guardar las credenciales", "clientCredentialsSaveDescription": "Sólo podrás verlo una vez. Asegúrate de copiarlo a un lugar seguro.", "generalSettingsDescription": "Configura la configuración general para este cliente", "clientUpdated": "Cliente actualizado", "clientUpdatedDescription": "El cliente ha sido actualizado.", "clientUpdateFailed": "Error al actualizar el cliente", "clientUpdateError": "Se ha producido un error al actualizar el cliente.", "sitesFetchFailed": "Error al obtener los sitios", "sitesFetchError": "Se ha producido un error al recuperar los sitios.", "olmErrorFetchReleases": "Se ha producido un error al recuperar las versiones de Olm.", "olmErrorFetchLatest": "Se ha producido un error al recuperar la última versión de Olm.", "enterCidrRange": "Ingresa el rango CIDR", "resourceEnableProxy": "Habilitar proxy público", "resourceEnableProxyDescription": "Habilite el proxy público para este recurso. Esto permite el acceso al recurso desde fuera de la red a través de la nube en un puerto abierto. Requiere configuración de Traefik.", "externalProxyEnabled": "Proxy externo habilitado", "addNewTarget": "Agregar nuevo destino", "targetsList": "Lista de destinos", "advancedMode": "Modo avanzado", "advancedSettings": "Configuración avanzada", "targetErrorDuplicateTargetFound": "Se encontró un destino duplicado", "healthCheckHealthy": "Saludable", "healthCheckUnhealthy": "No saludable", "healthCheckUnknown": "Desconocido", "healthCheck": "Chequeo de salud", "configureHealthCheck": "Configurar Chequeo de Salud", "configureHealthCheckDescription": "Configura la monitorización de salud para {target}", "enableHealthChecks": "Activar Chequeos de Salud", "enableHealthChecksDescription": "Controlar la salud de este objetivo. Puedes supervisar un punto final diferente al objetivo si es necesario.", "healthScheme": "Método", "healthSelectScheme": "Seleccionar método", "healthCheckPortInvalid": "El puerto de chequeo de salud debe estar entre 1 y 65535", "healthCheckPath": "Ruta", "healthHostname": "IP / Nombre del host", "healthPort": "Puerto", "healthCheckPathDescription": "La ruta para comprobar el estado de salud.", "healthyIntervalSeconds": "Intervalo saludable (seg)", "unhealthyIntervalSeconds": "Intervalo poco saludable (seg)", "IntervalSeconds": "Intervalo Saludable", "timeoutSeconds": "Tiempo agotado (seg)", "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 separada por comas de comandos que el usuario puede ejecutar con sudo.", "sshCreateHomeDir": "Crear directorio principal", "sshUnixGroups": "Grupos Unix", "sshUnixGroupsDescription": "Grupos Unix separados por comas 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.", "customHeaders": "Cabeceras personalizadas", "customHeadersDescription": "Nueva línea de cabeceras separada: Nombre de cabecera: valor", "headersValidationError": "Los encabezados deben estar en el formato: Nombre de cabecera: valor.", "saveHealthCheck": "Guardar Chequeo de Salud", "healthCheckSaved": "Chequeo de Salud Guardado", "healthCheckSavedDescription": "La configuración del chequeo de salud se ha guardado correctamente", "healthCheckError": "Error en el Chequeo de Salud", "healthCheckErrorDescription": "Ocurrió un error al guardar la configuración del chequeo de salud", "healthCheckPathRequired": "Se requiere la ruta del chequeo de salud", "healthCheckMethodRequired": "Se requiere el método HTTP", "healthCheckIntervalMin": "El intervalo de comprobación debe ser de al menos 5 segundos", "healthCheckTimeoutMin": "El tiempo de espera debe ser de al menos 1 segundo", "healthCheckRetryMin": "Los intentos de reintento deben ser de al menos 1", "httpMethod": "Método HTTP", "selectHttpMethod": "Seleccionar método HTTP", "domainPickerSubdomainLabel": "Subdominio", "domainPickerBaseDomainLabel": "Dominio base", "domainPickerSearchDomains": "Buscar dominios...", "domainPickerNoDomainsFound": "No se encontraron dominios", "domainPickerLoadingDomains": "Cargando dominios...", "domainPickerSelectBaseDomain": "Seleccionar dominio base...", "domainPickerNotAvailableForCname": "No disponible para dominios CNAME", "domainPickerEnterSubdomainOrLeaveBlank": "Ingrese subdominio o deje en blanco para usar dominio base.", "domainPickerEnterSubdomainToSearch": "Ingrese un subdominio para buscar y seleccionar entre dominios gratuitos disponibles.", "domainPickerFreeDomains": "Dominios gratuitos", "domainPickerSearchForAvailableDomains": "Buscar dominios disponibles", "domainPickerNotWorkSelfHosted": "Nota: Los dominios gratuitos proporcionados no están disponibles para instancias autogestionadas por ahora.", "resourceDomain": "Dominio", "resourceEditDomain": "Editar dominio", "siteName": "Nombre del sitio", "proxyPort": "Puerto", "resourcesTableProxyResources": "Público", "resourcesTableClientResources": "Privado", "resourcesTableNoProxyResourcesFound": "No se encontraron recursos de proxy.", "resourcesTableNoInternalResourcesFound": "No se encontraron recursos internos.", "resourcesTableDestination": "Destino", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Dirección del alias", "resourcesTableAliasAddressInfo": "Esta dirección es parte de la subred de utilidad de la organización. Se utiliza para resolver registros de alias usando resolución DNS interna.", "resourcesTableClients": "Clientes", "resourcesTableAndOnlyAccessibleInternally": "y solo son accesibles internamente cuando se conectan con un cliente.", "resourcesTableNoTargets": "Sin objetivos", "resourcesTableHealthy": "Saludable", "resourcesTableDegraded": "Degrado", "resourcesTableOffline": "Desconectado", "resourcesTableUnknown": "Desconocido", "resourcesTableNotMonitored": "No supervisado", "editInternalResourceDialogEditClientResource": "Editar recurso privado", "editInternalResourceDialogUpdateResourceProperties": "Actualizar la configuración del recurso y los controles de acceso para {resourceName}", "editInternalResourceDialogResourceProperties": "Propiedades del recurso", "editInternalResourceDialogName": "Nombre", "editInternalResourceDialogProtocol": "Protocolo", "editInternalResourceDialogSitePort": "Puerto del sitio", "editInternalResourceDialogTargetConfiguration": "Configuración de objetivos", "editInternalResourceDialogCancel": "Cancelar", "editInternalResourceDialogSaveResource": "Guardar recurso", "editInternalResourceDialogSuccess": "Éxito", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Recurso interno actualizado con éxito", "editInternalResourceDialogError": "Error", "editInternalResourceDialogFailedToUpdateInternalResource": "Error al actualizar el recurso interno", "editInternalResourceDialogNameRequired": "El nombre es requerido", "editInternalResourceDialogNameMaxLength": "El nombre no debe tener más de 255 caracteres", "editInternalResourceDialogProxyPortMin": "El puerto del proxy debe ser al menos 1", "editInternalResourceDialogProxyPortMax": "El puerto del proxy debe ser menor de 65536", "editInternalResourceDialogInvalidIPAddressFormat": "Formato de dirección IP inválido", "editInternalResourceDialogDestinationPortMin": "El puerto de destino debe ser al menos 1", "editInternalResourceDialogDestinationPortMax": "El puerto de destino debe ser menor de 65536", "editInternalResourceDialogPortModeRequired": "Protocolos, puerto proxy y puerto de destino son necesarios para el modo puerto", "editInternalResourceDialogMode": "Modo", "editInternalResourceDialogModePort": "Puerto", "editInternalResourceDialogModeHost": "Anfitrión", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Destino", "editInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.", "editInternalResourceDialogDestinationIPDescription": "La dirección IP o nombre de host del recurso en la red del sitio.", "editInternalResourceDialogDestinationCidrDescription": "El rango CIDR del recurso en la red del sitio.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Un alias DNS interno opcional para este recurso.", "createInternalResourceDialogNoSitesAvailable": "No hay sitios disponibles", "createInternalResourceDialogNoSitesAvailableDescription": "Necesita tener al menos un sitio de Newt con una subred configurada para crear recursos internos.", "createInternalResourceDialogClose": "Cerrar", "createInternalResourceDialogCreateClientResource": "Crear recurso privado", "createInternalResourceDialogCreateClientResourceDescription": "Crear un nuevo recurso que sólo será accesible a los clientes conectados a la organización", "createInternalResourceDialogResourceProperties": "Propiedades del recurso", "createInternalResourceDialogName": "Nombre", "createInternalResourceDialogSite": "Sitio", "selectSite": "Seleccionar sitio...", "noSitesFound": "Sitios no encontrados.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Puerto del sitio", "createInternalResourceDialogSitePortDescription": "Use este puerto para acceder al recurso en el sitio cuando se conecta con un cliente.", "createInternalResourceDialogTargetConfiguration": "Configuración de objetivos", "createInternalResourceDialogDestinationIPDescription": "La dirección IP o nombre de host del recurso en la red del sitio.", "createInternalResourceDialogDestinationPortDescription": "El puerto en la IP de destino donde el recurso es accesible.", "createInternalResourceDialogCancel": "Cancelar", "createInternalResourceDialogCreateResource": "Crear recurso", "createInternalResourceDialogSuccess": "Éxito", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Recurso interno creado con éxito", "createInternalResourceDialogError": "Error", "createInternalResourceDialogFailedToCreateInternalResource": "Error al crear recurso interno", "createInternalResourceDialogNameRequired": "El nombre es requerido", "createInternalResourceDialogNameMaxLength": "El nombre debe ser menor de 255 caracteres", "createInternalResourceDialogPleaseSelectSite": "Por favor seleccione un sitio", "createInternalResourceDialogProxyPortMin": "El puerto del proxy debe ser al menos 1", "createInternalResourceDialogProxyPortMax": "El puerto del proxy debe ser menor de 65536", "createInternalResourceDialogInvalidIPAddressFormat": "Formato de dirección IP inválido", "createInternalResourceDialogDestinationPortMin": "El puerto de destino debe ser al menos 1", "createInternalResourceDialogDestinationPortMax": "El puerto de destino debe ser menor de 65536", "createInternalResourceDialogPortModeRequired": "Protocolos, puerto proxy y puerto de destino son necesarios para el modo puerto", "createInternalResourceDialogMode": "Modo", "createInternalResourceDialogModePort": "Puerto", "createInternalResourceDialogModeHost": "Anfitrión", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Destino", "createInternalResourceDialogDestinationHostDescription": "La dirección IP o nombre de host del recurso en la red del sitio.", "createInternalResourceDialogDestinationCidrDescription": "El rango CIDR del recurso en la red del sitio.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opcional para este recurso.", "siteConfiguration": "Configuración", "siteAcceptClientConnections": "Aceptar conexiones de clientes", "siteAcceptClientConnectionsDescription": "Permitir a los dispositivos de usuario y clientes acceder a los recursos de este sitio. Esto se puede cambiar más tarde.", "siteAddress": "Dirección del sitio (Avanzado)", "siteAddressDescription": "La dirección interna del sitio. Debe estar dentro de la subred de la organización.", "siteNameDescription": "El nombre mostrado del sitio que se puede cambiar más adelante.", "autoLoginExternalIdp": "Inicio de sesión automático con IDP externo", "autoLoginExternalIdpDescription": "Redirigir inmediatamente al usuario al proveedor de identidad externo para autenticación.", "selectIdp": "Seleccionar IDP", "selectIdpPlaceholder": "Elegir un IDP...", "selectIdpRequired": "Por favor seleccione un IDP cuando el inicio de sesión automático esté habilitado.", "autoLoginTitle": "Redirigiendo", "autoLoginDescription": "Te estamos redirigiendo al proveedor de identidad externo para autenticación.", "autoLoginProcessing": "Preparando autenticación...", "autoLoginRedirecting": "Redirigiendo al inicio de sesión...", "autoLoginError": "Error de inicio de sesión automático", "autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.", "autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación.", "remoteExitNodeManageRemoteExitNodes": "Nodos remotos", "remoteExitNodeDescription": "Aloje su propio nodo de retransmisión y proxy server sin depender de terceros.", "remoteExitNodes": "Nodos", "searchRemoteExitNodes": "Buscar nodos...", "remoteExitNodeAdd": "Añadir Nodo", "remoteExitNodeErrorDelete": "Error al eliminar el nodo", "remoteExitNodeQuestionRemove": "¿Está seguro que desea eliminar el nodo de la organización?", "remoteExitNodeMessageRemove": "Una vez eliminado, el nodo ya no será accesible.", "remoteExitNodeConfirmDelete": "Confirmar eliminar nodo", "remoteExitNodeDelete": "Eliminar Nodo", "sidebarRemoteExitNodes": "Nodos remotos", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Secreto", "remoteExitNodeCreate": { "title": "Crear nodo remoto", "description": "Crea un nuevo nodo de retransmisión y proxy server autogestionado", "viewAllButton": "Ver todos los nodos", "strategy": { "title": "Estrategia de Creación", "description": "Selecciona cómo quieres crear el nodo remoto", "adopt": { "title": "Adoptar Nodo", "description": "Elija esto si ya tiene las credenciales para el nodo." }, "generate": { "title": "Generar Claves", "description": "Elija esto si desea generar nuevas claves para el nodo." } }, "adopt": { "title": "Adoptar Nodo Existente", "description": "Introduzca las credenciales del nodo existente que desea adoptar", "nodeIdLabel": "ID del nodo", "nodeIdDescription": "El ID del nodo existente que desea adoptar", "secretLabel": "Secreto", "secretDescription": "La clave secreta del nodo existente", "submitButton": "Adoptar Nodo" }, "generate": { "title": "Credenciales Generadas", "description": "Utilice estas credenciales generadas para configurar el nodo", "nodeIdTitle": "ID del nodo", "secretTitle": "Secreto", "saveCredentialsTitle": "Agregar Credenciales a la Configuración", "saveCredentialsDescription": "Agrega estas credenciales a tu archivo de configuración del nodo Pangolin autogestionado para completar la conexión.", "submitButton": "Crear Nodo" }, "validation": { "adoptRequired": "El ID del nodo y el secreto son necesarios al adoptar un nodo existente" }, "errors": { "loadDefaultsFailed": "Falló al cargar los valores predeterminados", "defaultsNotLoaded": "Valores predeterminados no cargados", "createFailed": "Error al crear el nodo" }, "success": { "created": "Nodo creado correctamente" } }, "remoteExitNodeSelection": "Selección de nodo", "remoteExitNodeSelectionDescription": "Seleccione un nodo a través del cual enrutar el tráfico para este sitio local", "remoteExitNodeRequired": "Un nodo debe ser seleccionado para sitios locales", "noRemoteExitNodesAvailable": "No hay nodos disponibles", "noRemoteExitNodesAvailableDescription": "No hay nodos disponibles para esta organización. Crea un nodo primero para usar sitios locales.", "exitNode": "Nodo de Salida", "country": "País", "rulesMatchCountry": "Actualmente basado en IP de origen", "managedSelfHosted": { "title": "Autogestionado", "description": "Servidor Pangolin autoalojado más fiable y de bajo mantenimiento con campanas y silbidos extra", "introTitle": "Pangolin autogestionado", "introDescription": "es una opción de despliegue diseñada para personas que quieren simplicidad y fiabilidad extra mientras mantienen sus datos privados y autoalojados.", "introDetail": "Con esta opción, todavía ejecuta su propio nodo Pangolin, sus túneles, terminación SSL y tráfico permanecen en su servidor. La diferencia es que la gestión y el control se gestionan a través de nuestro panel de control en la nube, que desbloquea una serie de ventajas:", "benefitSimplerOperations": { "title": "Operaciones simples", "description": "No necesitas ejecutar tu propio servidor de correo o configurar alertas complejas. Recibirás cheques de salud y alertas de tiempo de inactividad." }, "benefitAutomaticUpdates": { "title": "Actualizaciones automáticas", "description": "El tablero de la nube evolucionará rápidamente, por lo que obtendrá nuevas características y correcciones de errores sin tener que extraer manualmente nuevos contenedores cada vez." }, "benefitLessMaintenance": { "title": "Menos mantenimiento", "description": "No hay migraciones de base de datos, copias de seguridad o infraestructura extra para administrar. Lo manejamos en la nube." }, "benefitCloudFailover": { "title": "Fallo en la nube", "description": "Si tu nodo cae, tus túneles pueden fallar temporalmente a nuestros puntos de presencia en la nube hasta que lo vuelvas a conectar." }, "benefitHighAvailability": { "title": "Alta disponibilidad (PoPs)", "description": "También puede adjuntar múltiples nodos a su cuenta para redundancia y mejor rendimiento." }, "benefitFutureEnhancements": { "title": "Mejoras futuras", "description": "Estamos planeando añadir más herramientas analíticas, alertas y de administración para hacer su despliegue aún más robusto." }, "docsAlert": { "text": "Aprenda más acerca de la opción de autoalojamiento administrado en nuestra", "documentation": "documentación" }, "convertButton": "Convierte este nodo a autoalojado administrado" }, "internationaldomaindetected": "Dominio Internacional detectado", "willbestoredas": "Se almacenará como:", "roleMappingDescription": "Determinar cómo se asignan los roles a los usuarios cuando se registran cuando está habilitada la provisión automática.", "selectRole": "Seleccione un rol", "roleMappingExpression": "Expresión", "selectRolePlaceholder": "Elija un rol", "selectRoleDescription": "Seleccione un rol para asignar a todos los usuarios de este proveedor de identidad", "roleMappingExpressionDescription": "Introduzca una expresión JMESPath para extraer información de rol del token de ID", "idpTenantIdRequired": "El ID del cliente es obligatorio", "invalidValue": "Valor inválido", "idpTypeLabel": "Tipo de proveedor de identidad", "roleMappingExpressionPlaceholder": "e.g., contiene(grupos, 'administrador') && 'administrador' || 'miembro'", "idpGoogleConfiguration": "Configuración de Google", "idpGoogleConfigurationDescription": "Configurar las credenciales de Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Secreto del cliente de Google OAuth2", "idpAzureConfiguration": "Configuración de Azure Entra ID", "idpAzureConfigurationDescription": "Configurar credenciales de Azure Entra ID OAuth2", "idpTenantId": "ID del inquilino", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "ID de inquilino Azure (encontrado en la descripción de Azure Active Directory)", "idpAzureClientIdDescription": "ID de cliente de registro de Azure App", "idpAzureClientSecretDescription": "Azure App Registro Cliente secreto", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Configuración de Google", "idpAzureConfigurationTitle": "Configuración de Azure Entra ID", "idpTenantIdLabel": "ID del inquilino", "idpAzureClientIdDescription2": "ID de cliente de registro de Azure App", "idpAzureClientSecretDescription2": "Azure App Registro Cliente secreto", "idpGoogleDescription": "Proveedor OAuth2/OIDC de Google", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subred", "subnetDescription": "La subred para la configuración de red de esta organización.", "customDomain": "Dominio personalizado", "authPage": "Páginas de autenticación", "authPageDescription": "Establecer un dominio personalizado para las páginas de autenticación de la organización", "authPageDomain": "Dominio de la página Auth", "authPageBranding": "Marca personalizada", "authPageBrandingDescription": "Configure la marca que aparece en las páginas de autenticación de esta organización", "authPageBrandingUpdated": "Marca de la página de autenticación actualizada correctamente", "authPageBrandingRemoved": "Marca de la página de autenticación eliminada correctamente", "authPageBrandingRemoveTitle": "Eliminar marca de la página de autenticación", "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)", "brandingOrgTitle": "Título para la página de autenticación de la organización", "brandingOrgDescription": "{orgName} será reemplazado por el nombre de la organización", "brandingOrgSubtitle": "Subtítulo para la página de autenticación de la organización", "brandingResourceTitle": "Título para la página de autenticación de recursos", "brandingResourceSubtitle": "Subtítulo para la página de autenticación de recursos", "brandingResourceDescription": "{resourceName} será reemplazado por el nombre de la organización", "saveAuthPageDomain": "Guardar dominio", "saveAuthPageBranding": "Guardar marca", "removeAuthPageBranding": "Eliminar marca", "noDomainSet": "Ningún dominio establecido", "changeDomain": "Cambiar dominio", "selectDomain": "Seleccionar dominio", "restartCertificate": "Reiniciar certificado", "editAuthPageDomain": "Editar dominio Auth Page", "setAuthPageDomain": "Establecer dominio Auth Page", "failedToFetchCertificate": "Error al obtener el certificado", "failedToRestartCertificate": "Error al reiniciar el certificado", "addDomainToEnableCustomAuthPages": "Los usuarios podrán acceder a la página de inicio de sesión de la organización y completar la autenticación de recursos utilizando este dominio.", "selectDomainForOrgAuthPage": "Seleccione un dominio para la página de autenticación de la organización", "domainPickerProvidedDomain": "Dominio proporcionado", "domainPickerFreeProvidedDomain": "Dominio proporcionado gratis", "domainPickerVerified": "Verificado", "domainPickerUnverified": "Sin verificar", "domainPickerInvalidSubdomainStructure": "Este subdominio contiene caracteres o estructura no válidos. Se limpiará automáticamente al guardar.", "domainPickerError": "Error", "domainPickerErrorLoadDomains": "Error al cargar los dominios de la organización", "domainPickerErrorCheckAvailability": "No se pudo comprobar la disponibilidad del dominio", "domainPickerInvalidSubdomain": "Subdominio inválido", "domainPickerInvalidSubdomainRemoved": "La entrada \"{sub}\" fue eliminada porque no es válida.", "domainPickerInvalidSubdomainCannotMakeValid": "No se ha podido hacer válido \"{sub}\" para {domain}.", "domainPickerSubdomainSanitized": "Subdominio saneado", "domainPickerSubdomainCorrected": "\"{sub}\" fue corregido a \"{sanitized}\"", "orgAuthSignInTitle": "Inicio de sesión de organización", "orgAuthChooseIdpDescription": "Elige tu proveedor de identidad para continuar", "orgAuthNoIdpConfigured": "Esta organización no tiene ningún proveedor de identidad configurado. En su lugar puedes iniciar sesión con tu identidad de Pangolin.", "orgAuthSignInWithPangolin": "Iniciar sesión con Pangolin", "orgAuthSignInToOrg": "Iniciar sesión en una organización", "orgAuthSelectOrgTitle": "Inicio de sesión de organización", "orgAuthSelectOrgDescription": "Ingrese el ID de su organización para continuar", "orgAuthOrgIdPlaceholder": "tu-organización", "orgAuthOrgIdHelp": "Ingrese el identificador único de su organización", "orgAuthSelectOrgHelp": "Después de ingresar el ID de su organización, se le llevará a la página de inicio de sesión de su organización donde podrá usar SSO o sus credenciales de organización.", "orgAuthRememberOrgId": "Recordar este ID de organización", "orgAuthBackToSignIn": "Volver a iniciar sesión estándar", "orgAuthNoAccount": "¿No tienes una cuenta?", "subscriptionRequiredToUse": "Se requiere una suscripción para utilizar esta función.", "mustUpgradeToUse": "Debes actualizar tu suscripción para usar esta función.", "subscriptionRequiredTierToUse": "Esta función requiere {tier} o superior.", "upgradeToTierToUse": "Actualiza a {tier} o superior para usar esta función.", "subscriptionTierTier1": "Inicio", "subscriptionTierTier2": "Equipo", "subscriptionTierTier3": "Negocio", "subscriptionTierEnterprise": "Empresa", "idpDisabled": "Los proveedores de identidad están deshabilitados.", "orgAuthPageDisabled": "La página de autenticación de la organización está deshabilitada.", "domainRestartedDescription": "Verificación de dominio reiniciada con éxito", "resourceAddEntrypointsEditFile": "Editar archivo: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Editar archivo: docker-compose.yml", "emailVerificationRequired": "Se requiere verificación de correo electrónico. Por favor, inicie sesión de nuevo a través de {dashboardUrl}/auth/login complete este paso. Luego, vuelva aquí.", "twoFactorSetupRequired": "La configuración de autenticación de doble factor es requerida. Por favor, inicia sesión de nuevo a través de {dashboardUrl}/auth/login completa este paso. Luego, vuelve aquí.", "additionalSecurityRequired": "Seguridad adicional requerida", "organizationRequiresAdditionalSteps": "Esta organización requiere pasos de seguridad adicionales antes de poder acceder a los recursos.", "completeTheseSteps": "Completa estos pasos", "enableTwoFactorAuthentication": "Habilitar autenticación de doble factor", "completeSecuritySteps": "Pasos de seguridad completos", "securitySettings": "Ajustes de seguridad", "dangerSection": "Zona de peligro", "dangerSectionDescription": "Eliminar permanentemente todos los datos asociados con esta organización", "securitySettingsDescription": "Configurar políticas de seguridad para la organización", "requireTwoFactorForAllUsers": "Requiere autenticación de doble factor para todos los usuarios", "requireTwoFactorDescription": "Cuando está activado, todos los usuarios internos de esta organización deben tener habilitada la autenticación de dos factores para acceder a la organización.", "requireTwoFactorDisabledDescription": "Esta característica requiere una licencia válida (Enterprise) o una suscripción activa (SaBudget)", "requireTwoFactorCannotEnableDescription": "Debes habilitar la autenticación de doble factor para tu cuenta antes de aplicarla a todos los usuarios", "maxSessionLength": "Longitud máxima de la sesión", "maxSessionLengthDescription": "Establecer la duración máxima de las sesiones de usuario. Después de este tiempo, los usuarios tendrán que volver a autenticarse.", "maxSessionLengthDisabledDescription": "Esta característica requiere una licencia válida (Enterprise) o una suscripción activa (SaBudget)", "selectSessionLength": "Seleccionar duración de sesión", "unenforced": "No aplicado", "1Hour": "1 hora", "3Hours": "3 horas", "6Hours": "6 horas", "12Hours": "12 horas", "1DaySession": "1 día", "3Days": "3 días", "7Days": "7 días", "14Days": "14 días", "30DaysSession": "30 días", "90DaysSession": "90 días", "180DaysSession": "180 días", "passwordExpiryDays": "Caduca la contraseña", "editPasswordExpiryDescription": "Establecer el número de días antes de que los usuarios tengan que cambiar su contraseña.", "selectPasswordExpiry": "Seleccione la contraseña expirada", "30Days": "30 días", "1Day": "1 día", "60Days": "60 días", "90Days": "90 días", "180Days": "180 días", "1Year": "1 año", "subscriptionBadge": "Suscripción requerida", "securityPolicyChangeWarning": "Advertencia de cambio de política de seguridad", "securityPolicyChangeDescription": "Está a punto de cambiar la configuración de la política de seguridad. Después de guardar, puede que necesite volver a autenticarse para cumplir con estas actualizaciones de política. Todos los usuarios que no cumplan con los requisitos también tendrán que volver a autenticarse.", "securityPolicyChangeConfirmMessage": "Confirmo", "securityPolicyChangeWarningText": "Esto afectará a todos los usuarios de la organización", "authPageErrorUpdateMessage": "Ocurrió un error mientras se actualizaban los ajustes de la página auth", "authPageErrorUpdate": "No se puede actualizar la página de autenticación", "authPageDomainUpdated": "Dominio de la página de autenticación actualizado correctamente", "healthCheckNotAvailable": "Local", "rewritePath": "Reescribir Ruta", "rewritePathDescription": "Opcionalmente reescribe la ruta antes de reenviar al destino.", "continueToApplication": "Continuar a la aplicación", "checkingInvite": "Comprobando invitación", "setResourceHeaderAuth": "set-Resource HeaderAuth", "resourceHeaderAuthRemove": "Eliminar Auth del Encabezado", "resourceHeaderAuthRemoveDescription": "Autenticación de cabecera eliminada correctamente.", "resourceErrorHeaderAuthRemove": "Error al eliminar autenticación de cabecera", "resourceErrorHeaderAuthRemoveDescription": "No se pudo eliminar la autenticación de cabecera del recurso.", "resourceHeaderAuthProtectionEnabled": "Autenticación de cabecera habilitada", "resourceHeaderAuthProtectionDisabled": "Autenticación de cabecera desactivada", "headerAuthRemove": "Eliminar Auth del Encabezado", "headerAuthAdd": "Añadir autenticación de cabecera", "resourceErrorHeaderAuthSetup": "Error al establecer autenticación de cabecera", "resourceErrorHeaderAuthSetupDescription": "No se pudo establecer autenticación de cabecera para el recurso.", "resourceHeaderAuthSetup": "Autenticación de cabecera establecida correctamente", "resourceHeaderAuthSetupDescription": "La autenticación de cabecera se ha establecido correctamente.", "resourceHeaderAuthSetupTitle": "Establecer autenticación de cabecera", "resourceHeaderAuthSetupTitleDescription": "Establezca las credenciales básicas de autenticación (nombre de usuario y contraseña) para proteger este recurso con autenticación de HTTP Header. Acceda a él usando el formato https://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Establecer autenticación de cabecera", "actionSetResourceHeaderAuth": "Establecer autenticación de cabecera", "enterpriseEdition": "Edición corporativa", "unlicensed": "Sin licencia", "beta": "Beta", "manageUserDevices": "Dispositivos de usuario", "manageUserDevicesDescription": "Ver y administrar dispositivos que los usuarios utilizan para conectarse a recursos privados", "downloadClientBannerTitle": "Descargar cliente Pangolin", "downloadClientBannerDescription": "Descargue el cliente Pangolin para su sistema para conectarse a la red Pangolin y acceder a recursos de forma privada.", "manageMachineClients": "Administrar clientes de máquinas", "manageMachineClientsDescription": "Crear y administrar clientes que servidores y sistemas utilizan para conectarse de forma privada a recursos", "machineClientsBannerTitle": "Servidores y sistemas automatizados", "machineClientsBannerDescription": "Los clientes de máquinas son para servidores y sistemas automatizados que no están asociados con un usuario específico. Se autentican con una ID y un secreto, y pueden ejecutarse con Pangolin CLI, Olm CLI o Olm como un contenedor.", "machineClientsBannerPangolinCLI": "CLI de Pangolin", "machineClientsBannerOlmCLI": "CLI de Olm", "machineClientsBannerOlmContainer": "Contenedor de Olm", "clientsTableUserClients": "Usuario", "clientsTableMachineClients": "Maquina", "licenseTableValidUntil": "Válido hasta", "saasLicenseKeysSettingsTitle": "Licencias empresariales", "saasLicenseKeysSettingsDescription": "Generar y administrar claves de licencia Enterprise para instancias Pangolin autoalojadas", "sidebarEnterpriseLicenses": "Licencias", "generateLicenseKey": "Generar clave de licencia", "generateLicenseKeyForm": { "validation": { "emailRequired": "Por favor, introduzca una dirección de correo válida", "useCaseTypeRequired": "Por favor, seleccione un tipo de caso de uso", "firstNameRequired": "El nombre es obligatorio", "lastNameRequired": "Se requiere apellido", "primaryUseRequired": "Por favor describa su uso principal", "jobTitleRequiredBusiness": "El título de la tarea es obligatorio para uso empresarial", "industryRequiredBusiness": "La industria es necesaria para uso comercial", "stateProvinceRegionRequired": "Se requiere estado/Province/región", "postalZipCodeRequired": "Código Postal/ZIP es requerido", "companyNameRequiredBusiness": "El nombre de la empresa es obligatorio para uso comercial", "countryOfResidenceRequiredBusiness": "El país de residencia es obligatorio para uso de negocios", "countryRequiredPersonal": "El país es obligatorio para uso personal", "agreeToTermsRequired": "Debe aceptar los términos", "complianceConfirmationRequired": "Debe confirmar el cumplimiento de la Licencia Comercial Fossorial" }, "useCaseOptions": { "personal": { "title": "Uso personal", "description": "Para uso individual y no comercial, tales como aprendizaje, proyectos personales o experimentación." }, "business": { "title": "Uso de Negocio", "description": "Para uso dentro de organizaciones, empresas o actividades comerciales o generadoras de ingresos." } }, "steps": { "emailLicenseType": { "title": "Email y tipo de licencia", "description": "Introduzca su correo electrónico y elija su tipo de licencia" }, "personalInformation": { "title": "Información Personal", "description": "Cuéntanos acerca de ti" }, "contactInformation": { "title": "Información de contacto", "description": "Sus datos de contacto" }, "termsGenerate": { "title": "Términos y Generar", "description": "Revisar y aceptar términos para generar su licencia" } }, "alerts": { "commercialUseDisclosure": { "title": "Divulgación de uso", "description": "Seleccione el nivel de licencia que refleje con precisión su uso previsto. La Licencia Personal permite el uso libre del Software para actividades comerciales individuales, no comerciales o de pequeña escala con ingresos brutos anuales inferiores a $100,000 USD. Cualquier uso más allá de estos límites — incluyendo el uso dentro de una empresa, organización, u otro entorno de generación de ingresos — requiere una Licencia Empresarial válida y el pago de la cuota de licencia aplicable. Todos los usuarios, ya sean personales o empresariales, deben cumplir con las Condiciones de Licencia Comercial Fossorial." }, "trialPeriodInformation": { "title": "Información del período de prueba", "description": "Esta Clave de Licencia permite las funciones de la Empresa durante un período de evaluación de 7 días. El acceso continuado a las características de pago más allá del período de evaluación requiere una activación bajo una Licencia Personal o Empresarial válida. Para licencias de la Empresa, póngase en contacto con sales@pangolin.net." } }, "form": { "useCaseQuestion": "¿Estás usando Pangolin para uso personal o de negocios?", "firstName": "Nombre", "lastName": "Apellido", "jobTitle": "Trabajo", "primaryUseQuestion": "¿Para qué planeas usar principalmente Pangolin?", "industryQuestion": "¿Cuál es su industria?", "prospectiveUsersQuestion": "¿Cuántos usuarios potenciales esperas tener?", "prospectiveSitesQuestion": "¿Cuántos sitios potenciales (túneles) esperas tener?", "companyName": "Nombre de la empresa", "countryOfResidence": "País de residencia", "stateProvinceRegion": "Estado / Province / Región", "postalZipCode": "Código postal", "companyWebsite": "Sitio web de la empresa", "companyPhoneNumber": "Número de teléfono de la empresa", "country": "País", "phoneNumberOptional": "Número de teléfono (opcional)", "complianceConfirmation": "Confirmo que la información que he proporcionado es exacta y que estoy en conformidad con la Licencia Comercial Fossorial. Informar de información inexacta o identificar mal el uso del producto es una violación de la licencia y puede resultar en que su clave sea revocada." }, "buttons": { "close": "Cerrar", "previous": "Anterior", "next": "Siguiente", "generateLicenseKey": "Generar clave de licencia" }, "toasts": { "success": { "title": "Clave de licencia generada con éxito", "description": "Su clave de licencia ha sido generada y está lista para usar." }, "error": { "title": "Error al generar la clave de licencia", "description": "Se ha producido un error al generar la clave de licencia." } } }, "newPricingLicenseForm": { "title": "Obtener una licencia", "description": "Elige un plan y dinos cómo planeas usar Pangolin.", "chooseTier": "Elige tu plan", "viewPricingLink": "Ver precios, características y límites", "tiers": { "starter": { "title": "Interruptor", "description": "Características de la empresa, 25 usuarios, 25 sitios y soporte comunitario." }, "scale": { "title": "Escala", "description": "Características de la empresa, 50 usuarios, 50 sitios y soporte prioritario." } }, "personalUseOnly": "Solo uso personal (licencia gratuita, sin pago)", "buttons": { "continueToCheckout": "Continuar con el pago" }, "toasts": { "checkoutError": { "title": "Error de pago", "description": "No se pudo iniciar el pago. Por favor, inténtelo de nuevo." } } }, "priority": "Prioridad", "priorityDescription": "Las rutas de prioridad más alta son evaluadas primero. Prioridad = 100 significa orden automático (decisiones del sistema). Utilice otro número para hacer cumplir la prioridad manual.", "instanceName": "Nombre de instancia", "pathMatchModalTitle": "Configurar ruta coincidente", "pathMatchModalDescription": "Configurar cómo deben coincidir las peticiones entrantes en función de su ruta.", "pathMatchType": "Tipo de partida", "pathMatchPrefix": "Prefijo", "pathMatchExact": "Exacto", "pathMatchRegex": "Regex", "pathMatchValue": "Valor de ruta", "clear": "Claro", "saveChanges": "Guardar Cambios", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/ruta", "pathMatchPrefixHelp": "Ejemplo: /api coincide con /api, /api/users, etc.", "pathMatchExactHelp": "Ejemplo: /api coincide sólo con /api", "pathMatchRegexHelp": "Ejemplo: ^/api/.* coincide con /api/anything", "pathRewriteModalTitle": "Configurar ruta de reescritura", "pathRewriteModalDescription": "Transforma la ruta coincidente antes de reenviarla al objetivo.", "pathRewriteType": "Tipo de reescritura", "pathRewritePrefixOption": "Prefijo - Reemplazar prefijo", "pathRewriteExactOption": "Exacto - Reemplazar toda la ruta", "pathRewriteRegexOption": "Regex - Reemplazo de patrón", "pathRewriteStripPrefixOption": "Prefijo de clip - Quitar prefijo", "pathRewriteValue": "Reescribir valor", "pathRewriteRegexPlaceholder": "/new/$1", "pathRewriteDefaultPlaceholder": "/new-path", "pathRewritePrefixHelp": "Reemplazar el prefijo coincidente con este valor", "pathRewriteExactHelp": "Reemplaza toda la ruta con este valor cuando la ruta coincida exactamente", "pathRewriteRegexHelp": "Usar grupos de captura como $1, $2 para reemplazar", "pathRewriteStripPrefixHelp": "Dejar en blanco para el prefijo strip o proporcionar un nuevo prefijo", "pathRewritePrefix": "Prefijo", "pathRewriteExact": "Exacto", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Clip", "pathRewriteStripLabel": "clip", "sidebarEnableEnterpriseLicense": "Activar licencia corporativa", "cannotbeUndone": "Esto no se puede deshacer.", "toConfirm": "confirmar.", "deleteClientQuestion": "¿Está seguro que desea eliminar el cliente del sitio y la organización?", "clientMessageRemove": "Una vez eliminado, el cliente ya no podrá conectarse al sitio.", "sidebarLogs": "Registros", "request": "Solicitud", "requests": "Solicitudes", "logs": "Registros", "logsSettingsDescription": "Monitorear registros recolectados de esta organización", "searchLogs": "Buscar registros...", "action": "Accin", "actor": "Actor", "timestamp": "Timestamp", "accessLogs": "Registros de acceso", "exportCsv": "Exportar CSV", "exportError": "Error desconocido al exportar CSV", "exportCsvTooltip": "Dentro del rango de tiempo", "actorId": "ID de Actor", "allowedByRule": "Permitido por regla", "allowedNoAuth": "No se permite autorización", "validAccessToken": "Token de Acceso Válido", "validHeaderAuth": "Valid header auth", "validPincode": "Valid Pincode", "validPassword": "Contraseña válida", "validEmail": "Valid email", "validSSO": "Valid SSO", "resourceBlocked": "Recurso bloqueado", "droppedByRule": "Soltado por regla", "noSessions": "No hay sesiones", "temporaryRequestToken": "Token de solicitud temporal", "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Razón", "requestLogs": "Registros de Solicitud", "requestAnalytics": "Analítica de Solicitud", "host": "Anfitrión", "location": "Ubicación", "actionLogs": "Registros de acción", "sidebarLogsRequest": "Registros de Solicitud", "sidebarLogsAccess": "Registros de acceso", "sidebarLogsAction": "Registros de acción", "logRetention": "Retención de Log", "logRetentionDescription": "Administrar cuánto tiempo se conservan los diferentes tipos de registros para esta organización o desactivarlos", "requestLogsDescription": "Ver registros de solicitudes detallados para los recursos de esta organización", "requestAnalyticsDescription": "Ver análisis de solicitudes detalladas de recursos en esta organización", "logRetentionRequestLabel": "Retención de Registro de Solicitud", "logRetentionRequestDescription": "Cuánto tiempo conservar los registros de solicitudes", "logRetentionAccessLabel": "Retención de Log de Acceso", "logRetentionAccessDescription": "Cuánto tiempo retener los registros de acceso", "logRetentionActionLabel": "Retención de registro de acción", "logRetentionActionDescription": "Cuánto tiempo retener los registros de acción", "logRetentionDisabled": "Deshabilitado", "logRetention3Days": "3 días", "logRetention7Days": "7 días", "logRetention14Days": "14 días", "logRetention30Days": "30 días", "logRetention90Days": "90 días", "logRetentionForever": "Para siempre", "logRetentionEndOfFollowingYear": "Fin del año siguiente", "actionLogsDescription": "Ver un historial de acciones realizadas en esta organización", "accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización", "licenseRequiredToUse": "Se requiere una licencia Enterprise Edition para utilizar esta función. Esta característica también está disponible en Pangolin Cloud.", "ossEnterpriseEditionRequired": "La versión Enterprise es necesaria para utilizar esta función. Esta función también está disponible en Pangolin Cloud.", "certResolver": "Resolver certificado", "certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.", "selectCertResolver": "Seleccionar Resolver Certificado", "enterCustomResolver": "Introducir resolución personalizada", "preferWildcardCert": "Certificado de comodín preferido", "unverified": "Sin verificar", "domainSetting": "Ajustes de dominio", "domainSettingDescription": "Configurar ajustes para el dominio", "preferWildcardCertDescription": "Intentar generar un certificado comodín (requiere un resolvedor de certificados configurado correctamente).", "recordName": "Nombre del registro", "auto": "Auto", "TTL": "TTL", "howToAddRecords": "Cómo añadir registros", "dnsRecord": "Registros DNS", "required": "Requerido", "domainSettingsUpdated": "Configuración de dominio actualizada correctamente", "orgOrDomainIdMissing": "Falta el ID de organización o dominio", "loadingDNSRecords": "Cargando registros DNS...", "olmUpdateAvailableInfo": "Una versión actualizada de Olm está disponible. Por favor, actualice a la última versión para obtener la mejor experiencia.", "client": "Cliente", "proxyProtocol": "Configuración del Protocolo Proxy", "proxyProtocolDescription": "Configurar el protocolo de proxy para preservar las direcciones IP del cliente para los servicios TCP.", "enableProxyProtocol": "Habilitar protocolo proxy", "proxyProtocolInfo": "Conservar direcciones IP del cliente para backends TCP", "proxyProtocolVersion": "Versión del Protocolo Proxy", "version1": " Versión 1 (Recomendado)", "version2": "Versión 2", "versionDescription": "La versión 1 está basada en texto y es ampliamente soportada. La versión 2 es binaria y más eficiente pero menos compatible.", "warning": "Advertencia", "proxyProtocolWarning": "La aplicación backend debe configurarse para aceptar conexiones Proxy Protocol. Si el backend no soporta Proxy Protocol, activarlo romperá todas las conexiones, así que sólo habilítelo si sabe lo que está haciendo. Asegúrese de configurar su backend para que confíe en las cabeceras del protocolo Proxy de Traefik.", "restarting": "Reiniciando...", "manual": "Manual", "messageSupport": "Soporte de mensajes", "supportNotAvailableTitle": "Soporte no disponible", "supportNotAvailableDescription": "El soporte no está disponible en este momento. Puedes enviar un correo electrónico a support@pangolin.net.", "supportRequestSentTitle": "Solicitud de soporte enviada", "supportRequestSentDescription": "Su mensaje ha sido enviado con éxito.", "supportRequestFailedTitle": "Error al enviar la solicitud", "supportRequestFailedDescription": "Se ha producido un error al enviar su solicitud de soporte.", "supportSubjectRequired": "El asunto es obligatorio", "supportSubjectMaxLength": "El asunto debe tener 255 caracteres o menos", "supportMessageRequired": "El mensaje es obligatorio", "supportReplyTo": "Responder a", "supportSubject": "Asunto", "supportSubjectPlaceholder": "Introducir asunto", "supportMessage": "Mensaje", "supportMessagePlaceholder": "Introduce tu mensaje", "supportSending": "Enviando...", "supportSend": "Enviar", "supportMessageSent": "¡Mensaje enviado!", "supportWillContact": "¡Estaremos en contacto en breve!", "selectLogRetention": "Seleccionar retención de registro", "terms": "Términos", "privacy": "Privacidad", "security": "Seguridad", "docs": "Documentos", "deviceActivation": "Activación del dispositivo", "deviceCodeInvalidFormat": "El código debe tener 9 caracteres (por ejemplo, A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Código no válido o caducado", "deviceCodeVerifyFailed": "Error al verificar el código del dispositivo", "deviceCodeValidating": "Validando código de dispositivo...", "deviceCodeVerifying": "Verificando autorización del dispositivo...", "signedInAs": "Conectado como", "deviceCodeEnterPrompt": "Introduzca el código mostrado en el dispositivo", "continue": "Continuar", "deviceUnknownLocation": "Ubicación desconocida", "deviceAuthorizationRequested": "Esta autorización fue solicitada a {location} el {date}. Asegúrate de confiar en este dispositivo ya que tendrá acceso a la cuenta.", "deviceLabel": "Dispositivo: {deviceName}", "deviceWantsAccess": "quiere acceder a su cuenta", "deviceExistingAccess": "Acceso existente:", "deviceFullAccess": "Acceso total a tu cuenta", "deviceOrganizationsAccess": "Acceso a todas las organizaciones a las que su cuenta tiene acceso", "deviceAuthorize": "Autorizar a {applicationName}", "deviceConnected": "¡Dispositivo conectado!", "deviceAuthorizedMessage": "El dispositivo está autorizado para acceder a su cuenta. Por favor, vuelva a la aplicación cliente.", "pangolinCloud": "Nube de Pangolin", "viewDevices": "Ver dispositivos", "viewDevicesDescription": "Administra tus dispositivos conectados", "noDevices": "No hay dispositivos", "dateCreated": "Fecha de creación", "unnamedDevice": "Dispositivo sin nombre", "deviceQuestionRemove": "¿Está seguro que desea eliminar este dispositivo?", "deviceMessageRemove": "Esta acción no se puede deshacer.", "deviceDeleteConfirm": "Eliminar dispositivo", "deleteDevice": "Eliminar dispositivo", "errorLoadingDevices": "Error al cargar dispositivos", "failedToLoadDevices": "Error al cargar dispositivos", "deviceDeleted": "Dispositivo eliminado", "deviceDeletedDescription": "El dispositivo se ha eliminado correctamente.", "errorDeletingDevice": "Error al eliminar el dispositivo", "failedToDeleteDevice": "Error al eliminar el dispositivo", "showColumns": "Mostrar columnas", "hideColumns": "Ocultar columnas", "columnVisibility": "Visibilidad de la columna", "toggleColumn": "Cambiar columna {columnName}", "allColumns": "Todas las columnas", "defaultColumns": "Columnas por defecto", "customizeView": "Personalizar vista", "viewOptions": "Ver opciones", "selectAll": "Seleccionar todo", "selectNone": "No seleccionar", "selectedResources": "Recursos seleccionados", "enableSelected": "Habilitar seleccionados", "disableSelected": "Desactivar Seleccionado", "checkSelectedStatus": "Comprobar el estado de selección", "clients": "Clientes", "accessClientSelect": "Seleccionar clientes de máquina", "resourceClientDescription": "Clientes de máquina que pueden acceder a este recurso", "regenerate": "Regenerar", "credentials": "Credenciales", "savecredentials": "Guardar credenciales", "regenerateCredentialsButton": "Regenerar credenciales", "regenerateCredentials": "Regenerar credenciales", "generatedcredentials": "Credenciales generadas", "copyandsavethesecredentials": "Copiar y guardar estas credenciales", "copyandsavethesecredentialsdescription": "Estas credenciales no se mostrarán de nuevo después de salir de esta página. Guárdelas de forma segura ahora.", "credentialsSaved": "Credenciales guardadas", "credentialsSavedDescription": "Las credenciales se han regenerado y guardado correctamente.", "credentialsSaveError": "Error al guardar las credenciales", "credentialsSaveErrorDescription": "Se ha producido un error al regenerar y guardar las credenciales.", "regenerateCredentialsWarning": "Regenerar las credenciales invalidará las anteriores y causará una desconexión. Asegúrese de actualizar cualquier configuración que use estas credenciales.", "confirm": "Confirmar", "regenerateCredentialsConfirmation": "¿Está seguro que desea regenerar las credenciales?", "endpoint": "Endpoint", "Id": "Id", "SecretKey": "Clave secreta", "niceId": "ID bonita", "niceIdUpdated": "Bonito ID actualizado", "niceIdUpdatedSuccessfully": "Bonito ID actualizado correctamente", "niceIdUpdateError": "Error al actualizar Nice ID", "niceIdUpdateErrorDescription": "Se ha producido un error al actualizar el ID de Niza.", "niceIdCannotBeEmpty": "El ID de Niza no puede estar vacío", "enterIdentifier": "Introducir identificador", "identifier": "Identifier", "deviceLoginUseDifferentAccount": "¿No tú? Utilice una cuenta diferente.", "deviceLoginDeviceRequestingAccessToAccount": "Un dispositivo está solicitando acceso a esta cuenta.", "loginSelectAuthenticationMethod": "Seleccione un método de autenticación para continuar.", "noData": "Sin datos", "machineClients": "Clientes de la máquina", "install": "Instalar", "run": "Ejecutar", "clientNameDescription": "El nombre mostrado del cliente que se puede cambiar más adelante.", "clientAddress": "Dirección del cliente (Avanzado)", "setupFailedToFetchSubnet": "No se pudo obtener la subred por defecto", "setupSubnetAdvanced": "Subred (Avanzado)", "setupSubnetDescription": "La subred de la red interna de esta organización.", "setupUtilitySubnet": "Subred de utilidad (Avanzado)", "setupUtilitySubnetDescription": "La subred de direcciones alias y el servidor DNS de esta organización.", "siteRegenerateAndDisconnect": "Regenerar y desconectar", "siteRegenerateAndDisconnectConfirmation": "¿Está seguro que desea regenerar las credenciales y desconectar este sitio?", "siteRegenerateAndDisconnectWarning": "Esto regenerará las credenciales y desconectará inmediatamente el sitio. El sitio tendrá que reiniciarse con las nuevas credenciales.", "siteRegenerateCredentialsConfirmation": "¿Está seguro de que desea regenerar las credenciales de este sitio?", "siteRegenerateCredentialsWarning": "Esto regenerará las credenciales. El sitio permanecerá conectado hasta que lo reinicie manualmente y utilice las nuevas credenciales.", "clientRegenerateAndDisconnect": "Regenerar y desconectar", "clientRegenerateAndDisconnectConfirmation": "¿Está seguro que desea regenerar las credenciales y desconectar este cliente?", "clientRegenerateAndDisconnectWarning": "Esto regenerará las credenciales y desconectará inmediatamente al cliente. El cliente tendrá que reiniciarse con las nuevas credenciales.", "clientRegenerateCredentialsConfirmation": "¿Está seguro que desea regenerar las credenciales para este cliente?", "clientRegenerateCredentialsWarning": "Esto regenerará las credenciales. El cliente permanecerá conectado hasta que lo reinicie manualmente y utilice las nuevas credenciales.", "remoteExitNodeRegenerateAndDisconnect": "Regenerar y desconectar", "remoteExitNodeRegenerateAndDisconnectConfirmation": "¿Estás seguro de que quieres regenerar las credenciales y desconectar este nodo de salida remoto?", "remoteExitNodeRegenerateAndDisconnectWarning": "Esto regenerará las credenciales y desconectará inmediatamente el nodo de salida remoto. El nodo de salida remoto tendrá que reiniciarse con las nuevas credenciales.", "remoteExitNodeRegenerateCredentialsConfirmation": "¿Estás seguro de que quieres regenerar las credenciales para este nodo de salida remoto?", "remoteExitNodeRegenerateCredentialsWarning": "Esto regenerará las credenciales. El nodo de salida remoto permanecerá conectado hasta que lo reinicie manualmente y utilice las nuevas credenciales.", "agent": "Agente", "personalUseOnly": "Solo para uso personal", "loginPageLicenseWatermark": "Esta instancia está licenciada solo para uso personal.", "instanceIsUnlicensed": "Esta instancia no tiene licencia.", "portRestrictions": "Restricciones de puerto", "allPorts": "Todo", "custom": "Personalizado", "allPortsAllowed": "Todos los puertos permitidos", "allPortsBlocked": "Todos los puertos bloqueados", "tcpPortsDescription": "Especifique qué puertos TCP están permitidos para este recurso. Use '*' para todos los puertos, déjelo vacío para bloquear todos, o ingrese una lista separada por comas de puertos y rangos (por ejemplo, 80,443,8000-9000).", "udpPortsDescription": "Especifique qué puertos UDP están permitidos para este recurso. Use '*' para todos los puertos, déjelo vacío para bloquear todos, o ingrese una lista separada por comas de puertos y rangos (por ejemplo, 53,123,500-600).", "organizationLoginPageTitle": "Página de inicio de sesión de la organización", "organizationLoginPageDescription": "Personaliza la página de inicio de sesión para esta organización", "resourceLoginPageTitle": "Página de inicio de sesión de recursos", "resourceLoginPageDescription": "Personaliza la página de inicio de sesión para recursos individuales", "enterConfirmation": "Ingresar confirmación", "blueprintViewDetails": "Detalles", "defaultIdentityProvider": "Proveedor de identidad predeterminado", "defaultIdentityProviderDescription": "Cuando se selecciona un proveedor de identidad por defecto, el usuario será redirigido automáticamente al proveedor de autenticación.", "editInternalResourceDialogNetworkSettings": "Configuración de red", "editInternalResourceDialogAccessPolicy": "Política de acceso", "editInternalResourceDialogAddRoles": "Agregar roles", "editInternalResourceDialogAddUsers": "Agregar usuarios", "editInternalResourceDialogAddClients": "Agregar clientes", "editInternalResourceDialogDestinationLabel": "Destino", "editInternalResourceDialogDestinationDescription": "Especifique la dirección de destino para el recurso interno. Puede ser un nombre de host, dirección IP o rango CIDR dependiendo del modo seleccionado. Opcionalmente establezca un alias DNS interno para una identificación más fácil.", "editInternalResourceDialogPortRestrictionsDescription": "Restringir el acceso a puertos TCP/UDP específicos o permitir/bloquear todos los puertos.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Control de acceso", "editInternalResourceDialogAccessControlDescription": "Controla qué roles, usuarios y clientes de máquinas tienen acceso a este recurso cuando están conectados. Los administradores siempre tienen acceso.", "editInternalResourceDialogPortRangeValidationError": "El rango de puertos debe ser \"*\" para todos los puertos, o una lista separada por comas de puertos y rangos (por ejemplo, \"80,443,8000-9000\"). Los puertos deben estar entre 1 y 65535.", "internalResourceAuthDaemonStrategy": "Ubicación del demonio de autenticación SSSH", "internalResourceAuthDaemonStrategyDescription": "Elija dónde se ejecuta el daemon de autenticación SSH: en el sitio (Newt) o en un host remoto.", "internalResourceAuthDaemonDescription": "El daemon de autenticación SSSH maneja la firma de claves SSH y autenticación PAM para este recurso. Elija si se ejecuta en el sitio (Newt) o en un host remoto separado. Vea la documentación para más.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Seleccionar estrategia", "internalResourceAuthDaemonStrategyLabel": "Ubicación", "internalResourceAuthDaemonSite": "En el sitio", "internalResourceAuthDaemonSiteDescription": "Auth daemon corre en el sitio (Newt).", "internalResourceAuthDaemonRemote": "Host remoto", "internalResourceAuthDaemonRemoteDescription": "El daemon Auth corre en un host que no es el sitio.", "internalResourceAuthDaemonPort": "Puerto de demonio (opcional)", "orgAuthWhatsThis": "¿Dónde puedo encontrar el ID de mi organización?", "learnMore": "Más información", "backToHome": "Volver a inicio", "needToSignInToOrg": "¿Necesita usar el proveedor de identidad de su organización?", "maintenanceMode": "Modo de mantenimiento", "maintenanceModeDescription": "Muestra una página de mantenimiento a los visitantes", "maintenanceModeType": "Tipo de modo de mantenimiento", "showMaintenancePage": "Mostrar página de mantenimiento a los visitantes", "enableMaintenanceMode": "Habilitar modo de mantenimiento", "automatic": "Automático", "automaticModeDescription": "Mostrar página de mantenimiento solo cuando todos los objetivos de backend están caídos o no saludables. Su recurso continúa funcionando normalmente siempre que al menos un objetivo esté saludable.", "forced": "Forzado", "forcedModeDescription": "Mostrar siempre la página de mantenimiento independientemente de la salud del backend. Use esto para mantenimiento planificado cuando desee evitar todo acceso.", "warning:": "Advertencia:", "forcedeModeWarning": "Todo el tráfico será dirigido a la página de mantenimiento. Sus recursos de backend no recibirán solicitudes.", "pageTitle": "Título de la página", "pageTitleDescription": "El encabezado principal visible en la página de mantenimiento", "maintenancePageMessage": "Mensaje de mantenimiento", "maintenancePageMessagePlaceholder": "¡Volveremos pronto! Nuestro sitio está actualmente en mantenimiento programado.", "maintenancePageMessageDescription": "Mensaje detallado explicando el mantenimiento", "maintenancePageTimeTitle": "Tiempo estimado de finalización (Opcional)", "maintenanceTime": "Ej., 2 horas, 1 de noviembre a las 5:00 PM", "maintenanceEstimatedTimeDescription": "Cuando espera que el mantenimiento esté terminado", "editDomain": "Editar dominio", "editDomainDescription": "Seleccione un dominio para su recurso", "maintenanceModeDisabledTooltip": "Esta función requiere una licencia válida para ser habilitada.", "maintenanceScreenTitle": "Servicio temporalmente no disponible", "maintenanceScreenMessage": "Actualmente estamos experimentando dificultades técnicas. Por favor regrese pronto.", "maintenanceScreenEstimatedCompletion": "Estimado completado:", "createInternalResourceDialogDestinationRequired": "Se requiere destino", "available": "Disponible", "archived": "Archivado", "noArchivedDevices": "No se encontraron dispositivos archivados", "deviceArchived": "Dispositivo archivado", "deviceArchivedDescription": "El dispositivo se ha archivado correctamente.", "errorArchivingDevice": "Error al archivar dispositivo", "failedToArchiveDevice": "Error al archivar el dispositivo", "deviceQuestionArchive": "¿Está seguro que desea archivar este dispositivo?", "deviceMessageArchive": "El dispositivo será archivado y eliminado de su lista de dispositivos activos.", "deviceArchiveConfirm": "Archivar dispositivo", "archiveDevice": "Archivar dispositivo", "archive": "Archivar", "deviceUnarchived": "Dispositivo desarchivado", "deviceUnarchivedDescription": "El dispositivo se ha desarchivado correctamente.", "errorUnarchivingDevice": "Error al desarchivar dispositivo", "failedToUnarchiveDevice": "Error al desarchivar el dispositivo", "unarchive": "Desarchivar", "archiveClient": "Archivar cliente", "archiveClientQuestion": "¿Está seguro que desea archivar este cliente?", "archiveClientMessage": "El cliente será archivado y eliminado de su lista de clientes activos.", "archiveClientConfirm": "Archivar cliente", "blockClient": "Bloquear cliente", "blockClientQuestion": "¿Estás seguro de que quieres bloquear a este cliente?", "blockClientMessage": "El dispositivo será forzado a desconectarse si está conectado actualmente. Puede desbloquear el dispositivo más tarde.", "blockClientConfirm": "Bloquear cliente", "active": "Activo", "usernameOrEmail": "Nombre de usuario o email", "selectYourOrganization": "Seleccione su organización", "signInTo": "Iniciar sesión en", "signInWithPassword": "Continuar con la contraseña", "noAuthMethodsAvailable": "No hay métodos de autenticación disponibles para esta organización.", "enterPassword": "Introduzca su contraseña", "enterMfaCode": "Introduzca el código de su aplicación de autenticación", "securityKeyRequired": "Utilice su clave de seguridad para iniciar sesión.", "needToUseAnotherAccount": "¿Necesitas usar una cuenta diferente?", "loginLegalDisclaimer": "Al hacer clic en los botones de abajo, reconoces que has leído, comprendido, y acepta los Términos de Servicio y Política de Privacidad.", "termsOfService": "Términos de Servicio", "privacyPolicy": "Política de privacidad", "userNotFoundWithUsername": "Ningún usuario encontrado con ese nombre de usuario.", "verify": "Verificar", "signIn": "Iniciar sesión", "forgotPassword": "¿Olvidaste la contraseña?", "orgSignInTip": "Si has iniciado sesión antes, puedes introducir tu nombre de usuario o correo electrónico arriba para autenticarte con el proveedor de identidad de tu organización. ¡Es más fácil!", "continueAnyway": "Continuar de todos modos", "dontShowAgain": "No volver a mostrar", "orgSignInNotice": "¿Sabía usted?", "signupOrgNotice": "¿Intentando iniciar sesión?", "signupOrgTip": "¿Estás intentando iniciar sesión a través del proveedor de identidad de tu organización?", "signupOrgLink": "Inicia sesión o regístrate con tu organización", "verifyEmailLogInWithDifferentAccount": "Usar una cuenta diferente", "logIn": "Iniciar sesión", "deviceInformation": "Información del dispositivo", "deviceInformationDescription": "Información sobre el dispositivo y el agente", "deviceSecurity": "Seguridad del dispositivo", "deviceSecurityDescription": "Información de postura de seguridad del dispositivo", "platform": "Plataforma", "macosVersion": "versión macOS", "windowsVersion": "Versión de Windows", "iosVersion": "Versión de iOS", "androidVersion": "Versión de Android", "osVersion": "Versión del SO", "kernelVersion": "Versión de Kernel", "deviceModel": "Modelo de dispositivo", "serialNumber": "Número Serial", "hostname": "Hostname", "firstSeen": "Primer detectado", "lastSeen": "Último Visto", "biometricsEnabled": "Biometría habilitada", "diskEncrypted": "Disco cifrado", "firewallEnabled": "Cortafuegos activado", "autoUpdatesEnabled": "Actualizaciones automáticas habilitadas", "tpmAvailable": "TPM disponible", "windowsAntivirusEnabled": "Antivirus activado", "macosSipEnabled": "Protección de integridad del sistema (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Modo Sigilo Firewall", "linuxAppArmorEnabled": "AppArmor", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "Ver información y ajustes del dispositivo", "devicePendingApprovalDescription": "Este dispositivo está esperando su aprobación", "deviceBlockedDescription": "Este dispositivo está actualmente bloqueado. No podrá conectarse a ningún recurso a menos que sea desbloqueado.", "unblockClient": "Desbloquear cliente", "unblockClientDescription": "El dispositivo ha sido desbloqueado", "unarchiveClient": "Desarchivar cliente", "unarchiveClientDescription": "El dispositivo ha sido desarchivado", "block": "Bloque", "unblock": "Desbloquear", "deviceActions": "Acciones del dispositivo", "deviceActionsDescription": "Administrar estado y acceso al dispositivo", "devicePendingApprovalBannerDescription": "Este dispositivo está pendiente de aprobación. No podrá conectarse a recursos hasta que sea aprobado.", "connected": "Conectado", "disconnected": "Desconectado", "approvalsEmptyStateTitle": "Aprobaciones de dispositivo no habilitadas", "approvalsEmptyStateDescription": "Habilita las aprobaciones de dispositivos para que los roles requieran aprobación del administrador antes de que los usuarios puedan conectar nuevos dispositivos.", "approvalsEmptyStateStep1Title": "Ir a roles", "approvalsEmptyStateStep1Description": "Navega a la configuración de roles de tu organización para configurar las aprobaciones de dispositivos.", "approvalsEmptyStateStep2Title": "Habilitar aprobaciones de dispositivo", "approvalsEmptyStateStep2Description": "Editar un rol y habilitar la opción 'Requerir aprobaciones de dispositivos'. Los usuarios con este rol necesitarán la aprobación del administrador para nuevos dispositivos.", "approvalsEmptyStatePreviewDescription": "Vista previa: Cuando está habilitado, las solicitudes de dispositivo pendientes aparecerán aquí para su revisión", "approvalsEmptyStateButtonText": "Administrar roles" } ================================================ FILE: messages/fr-FR.json ================================================ { "setupCreate": "Créer l'organisation, le site et les ressources", "headerAuthCompatibilityInfo": "Activez ceci pour forcer une réponse 401 Unauthorized lorsque le jeton d'authentification est manquant. Cela est nécessaire pour les navigateurs ou les bibliothèques HTTP spécifiques qui n'envoient pas de credentials sans un challenge du serveur.", "headerAuthCompatibility": "Compatibilité étendue", "setupNewOrg": "Nouvelle organisation", "setupCreateOrg": "Créer une organisation", "setupCreateResources": "Créer des ressources", "setupOrgName": "Nom de l'organisation", "orgDisplayName": "Ceci est le nom d'affichage de l'organisation.", "orgId": "ID de l'organisation", "setupIdentifierMessage": "C'est l'identifiant unique de l'organisation.", "setupErrorIdentifier": "Cet ID est déjà utilisé. Veuillez en choisir un autre.", "componentsErrorNoMemberCreate": "Vous n'êtes actuellement membre d'aucune organisation. Créez une organisation pour commencer.", "componentsErrorNoMember": "Vous n'êtes actuellement membre d'aucune organisation.", "welcome": "Bienvenue sur Pangolin !", "welcomeTo": "Bienvenue chez", "componentsCreateOrg": "Créer une organisation", "componentsMember": "Vous {count, plural, =0 {n'} other {} }êtes membre {count, plural, =0 {d'aucune organisation} one {d'une organisation} other {de # organisations}}.", "componentsInvalidKey": "Clés de licence invalides ou expirées détectées. Veuillez respecter les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "dismiss": "Rejeter", "subscriptionViolationMessage": "Vous dépassez vos limites pour votre forfait actuel. Corrigez le problème en supprimant des sites, des utilisateurs ou d'autres ressources pour rester dans votre forfait.", "subscriptionViolationViewBilling": "Voir la facturation", "componentsLicenseViolation": "Violation de licence : ce serveur utilise {usedSites} nœuds, ce qui dépasse la limite autorisée de {maxSites} nœuds. Respectez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "componentsSupporterMessage": "Merci de soutenir Pangolin en tant que {tier}!", "inviteErrorNotValid": "Nous sommes désolés, mais il semble que l'invitation à laquelle vous essayez d'accéder n'ait pas été acceptée ou ne soit plus valide.", "inviteErrorUser": "Nous sommes désolés, mais il semble que l'invitation à laquelle vous essayez d'accéder ne soit pas pour cet utilisateur.", "inviteLoginUser": "Veuillez vous assurer que vous êtes connecté avec le bon compte.", "inviteErrorNoUser": "Nous sommes désolés, mais il semble que l'invitation à laquelle vous essayez d'accéder ne concerne pas un utilisateur existant.", "inviteCreateUser": "Veuillez d'abord créer un compte.", "goHome": "Retour à l'accueil", "inviteLogInOtherUser": "Se connecter en tant qu'utilisateur différent", "createAnAccount": "Créer un compte", "inviteNotAccepted": "Invitation non acceptée", "authCreateAccount": "Créez un compte pour commencer", "authNoAccount": "Vous n'avez pas de compte ?", "email": "Adresse mail", "password": "Mot de passe", "confirmPassword": "Confirmer le mot de passe", "createAccount": "Créer un compte", "viewSettings": "Afficher les paramètres", "delete": "Supprimer", "name": "Nom", "online": "En ligne", "offline": "Hors ligne", "site": "Nœud", "dataIn": "Données reçues", "dataOut": "Données émises", "connectionType": "Type de connexion", "tunnelType": "Type de tunnel", "local": "Locale", "edit": "Modifier", "siteConfirmDelete": "Confirmer la suppression du nœud", "siteDelete": "Supprimer le nœud", "siteMessageRemove": "Une fois supprimé, le nœud ne sera plus accessible. Toutes les cibles associées au nœud seront également supprimées.", "siteQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce nœud de l'organisation ?", "siteManageSites": "Gérer les nœuds", "siteDescription": "Créer et gérer des sites pour activer la connectivité aux réseaux privés", "sitesBannerTitle": "Se connecter à n'importe quel réseau", "sitesBannerDescription": "Un site est une connexion à un réseau distant qui permet à Pangolin de fournir aux utilisateurs l'accès à des ressources, publiques ou privées, n'importe où. Installez le connecteur de réseau du site (Newt) partout où vous pouvez exécuter un binaire ou un conteneur pour établir la connexion.", "sitesBannerButtonText": "Installer le site", "approvalsBannerTitle": "Approuver ou refuser l'accès à l'appareil", "approvalsBannerDescription": "Examinez et approuvez ou refusez les demandes d'accès à l'appareil des utilisateurs. Lorsque les autorisations de l'appareil sont requises, les utilisateurs doivent obtenir l'approbation de l'administrateur avant que leurs appareils puissent se connecter aux ressources de votre organisation.", "approvalsBannerButtonText": "En savoir plus", "siteCreate": "Créer un nœud", "siteCreateDescription2": "Suivez les étapes ci-dessous pour créer et connecter un nouveau nœud", "siteCreateDescription": "Créer un nouveau site pour commencer à connecter des ressources", "close": "Fermer", "siteErrorCreate": "Erreur lors de la création du nœud", "siteErrorCreateKeyPair": "Clés ou nœud par défaut introuvable", "siteErrorCreateDefaults": "Les valeurs par défaut du nœud sont introuvables", "method": "Méthode", "siteMethodDescription": "C'est ainsi que vous exposerez les connexions.", "siteLearnNewt": "Apprenez à installer Newt sur votre système", "siteSeeConfigOnce": "Vous ne pourrez voir la configuration qu'une seule fois.", "siteLoadWGConfig": "Chargement de la configuration WireGuard...", "siteDocker": "Développer pour obtenir plus de détails sur le déploiement Docker", "toggle": "Activer/désactiver", "dockerCompose": "Docker Compose", "dockerRun": "Docker Run", "siteLearnLocal": "Les nœuds locaux ne font pas de tunnel, en savoir plus", "siteConfirmCopy": "J'ai copié la configuration", "searchSitesProgress": "Rechercher des nœuds...", "siteAdd": "Ajouter un nœud", "siteInstallNewt": "Installer Newt", "siteInstallNewtDescription": "Faites fonctionner Newt sur votre système", "WgConfiguration": "Configuration WireGuard", "WgConfigurationDescription": "Utilisez la configuration suivante pour vous connecter au réseau", "operatingSystem": "Système d'exploitation", "commands": "Commandes", "recommended": "Recommandé", "siteNewtDescription": "Pour une meilleure expérience d'utilisateur, utilisez Newt. Newt se base sur le protocole WireGuard et vous permet de vous connecter à vos ressources privées, par leur adresse LAN sur votre réseau privé, à partir de Pangolin.", "siteRunsInDocker": "Exécute dans Docker", "siteRunsInShell": "Fonctionne depuis le shell sur macOS, Linux et Windows", "siteErrorDelete": "Erreur lors de la suppression du nœud", "siteErrorUpdate": "Impossible de mettre à jour le nœud", "siteErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour du nœud.", "siteUpdated": "Nœud mis à jour", "siteUpdatedDescription": "Le nœud a été mis à jour.", "siteGeneralDescription": "Configurer les paramètres par défaut de ce nœud", "siteSettingDescription": "Configurer les paramètres du site", "siteSetting": "Paramètres de {siteName}", "siteNewtTunnel": "Site Newt (Recommandé)", "siteNewtTunnelDescription": "La façon la plus simple de créer un point d'entrée dans n'importe quel réseau. Pas de configuration supplémentaire.", "siteWg": "WireGuard basique", "siteWgDescription": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise.", "siteWgDescriptionSaas": "Utilisez n'importe quel client WireGuard pour établir un tunnel. Configuration NAT manuelle requise.", "siteLocalDescription": "Ressources locales seulement. Pas de tunneling.", "siteLocalDescriptionSaas": "Ressources locales uniquement. Pas de tunneling. Disponible uniquement sur les nœuds distants.", "siteSeeAll": "Voir tous les nœuds", "siteTunnelDescription": "Déterminer comment vous voulez vous connecter au site", "siteNewtCredentials": "Identifiants", "siteNewtCredentialsDescription": "Voici comment le site s'authentifiera avec le serveur", "remoteNodeCredentialsDescription": "Voici comment le nœud distant s'authentifiera avec le serveur", "siteCredentialsSave": "Enregistrer les informations d'identification", "siteCredentialsSaveDescription": "Vous ne pourrez voir cela qu'une seule fois. Assurez-vous de l'enregistrer dans un endroit sécurisé.", "siteInfo": "Informations du nœud", "status": "Statut", "shareTitle": "Gérer les liens partageables", "shareDescription": "Créez des liens partageables pour accorder un accès temporaire ou permanent aux ressources de proxy", "shareSearch": "Rechercher des liens partageables...", "shareCreate": "Créer un lien partageable", "shareErrorDelete": "Impossible de supprimer le lien", "shareErrorDeleteMessage": "Une erreur s'est produite lors de la suppression du lien", "shareDeleted": "Lien supprimé", "shareDeletedDescription": "Le lien a été supprimé", "shareTokenDescription": "Le jeton d'accès peut être passé de deux façons : en tant que paramètre de requête ou dans les en-têtes de la requête. Elles doivent être transmises par le client à chaque demande d'accès authentifié.", "accessToken": "Jeton d'accès", "usageExamples": "Exemples d'utilisation", "tokenId": "ID du jeton", "requestHeades": "En-têtes de la requête", "queryParameter": "Paramètre de requête", "importantNote": "Note importante", "shareImportantDescription": "Pour des raisons de sécurité, l'utilisation des en-têtes est recommandée par rapport aux paramètres de la requête, dans la mesure du possible, car les paramètres de requête peuvent être enregistrés dans les journaux du serveur ou dans l'historique du navigateur.", "token": "Jeton", "shareTokenSecurety": "Gardez le jeton d'accès sécurisé. Ne le partagez pas dans des zones accessibles au public ou dans du code côté client.", "shareErrorFetchResource": "Impossible de récupérer les ressources", "shareErrorFetchResourceDescription": "Une erreur est survenue lors de la récupération des ressources", "shareErrorCreate": "Impossible de créer le lien partageable", "shareErrorCreateDescription": "Une erreur s'est produite lors de la création du lien partageable", "shareCreateDescription": "N'importe qui avec ce lien peut accéder à la ressource", "shareTitleOptional": "Titre (facultatif)", "expireIn": "Expire dans", "neverExpire": "N'expire jamais", "shareExpireDescription": "Le délai d'expiration correspond à la période pendant laquelle le lien sera utilisable et permettra d'accéder à la ressource. Passé ce délai, le lien ne fonctionnera plus et les utilisateurs qui l'ont utilisé perdront l'accès à la ressource.", "shareSeeOnce": "Vous ne pourrez voir ce lien qu'une seule fois. N'oubliez pas de le copier.", "shareAccessHint": "N'importe qui avec ce lien peut accéder à la ressource. Partagez-le avec précaution.", "shareTokenUsage": "Voir l'utilisation du jeton d'accès", "createLink": "Créer un lien", "resourcesNotFound": "Aucune ressource trouvée", "resourceSearch": "Rechercher des ressources", "openMenu": "Ouvrir le menu", "resource": "Ressource", "title": "Titre de la page", "created": "Créé", "expires": "Expire", "never": "Jamais", "shareErrorSelectResource": "Veuillez sélectionner une ressource", "proxyResourceTitle": "Gérer les ressources publiques", "proxyResourceDescription": "Créer et gérer des ressources accessibles au public via un navigateur web", "proxyResourcesBannerTitle": "Accès public basé sur le Web", "proxyResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS ou TCP/UDP accessibles par tout le monde sur Internet via un navigateur Web. Contrairement aux ressources privées, elles n'exigent pas de logiciel côté client et peuvent inclure des politiques d'accès basées sur l'identité et le contexte.", "clientResourceTitle": "Gérer les ressources privées", "clientResourceDescription": "Créer et gérer des ressources qui ne sont accessibles que via un client connecté", "privateResourcesBannerTitle": "Accès privé sans confiance", "privateResourcesBannerDescription": "Les ressources privées utilisent la sécurité sans confiance, garantissant que les utilisateurs et les machines ne peuvent accéder qu'aux ressources que vous accordez explicitement. Connectez les appareils utilisateur ou les clients machines à ces ressources via un réseau privé virtuel sécurisé.", "resourcesSearch": "Chercher des ressources...", "resourceAdd": "Ajouter une ressource", "resourceErrorDelte": "Erreur lors de la de suppression de la ressource", "authentication": "Authentification", "protected": "Protégé", "notProtected": "Non Protégé", "resourceMessageRemove": "Une fois supprimée, la ressource ne sera plus accessible. Toutes les cibles associées à la ressource seront également supprimées.", "resourceQuestionRemove": "Êtes-vous sûr de vouloir retirer la ressource de l'organisation ?", "resourceHTTP": "Ressource HTTPS", "resourceHTTPDescription": "Proxy les demandes sur HTTPS en utilisant un nom de domaine entièrement qualifié.", "resourceRaw": "Ressource TCP/UDP brute", "resourceRawDescription": "Proxy les demandes sur TCP/UDP brut en utilisant un numéro de port.", "resourceRawDescriptionCloud": "Requêtes de proxy sur TCP/UDP brute en utilisant un numéro de port. REQUISE L'UTILISATION D'UN Nœud DE REMOTE.", "resourceCreate": "Créer une ressource", "resourceCreateDescription": "Suivez les étapes ci-dessous pour créer une nouvelle ressource", "resourceSeeAll": "Voir toutes les ressources", "resourceInfo": "Informations sur la ressource", "resourceNameDescription": "Ceci est le nom d'affichage de la ressource.", "siteSelect": "Sélectionnez un nœud", "siteSearch": "Chercher un nœud", "siteNotFound": "Aucun nœud trouvé.", "selectCountry": "Sélectionnez un pays", "searchCountries": "Recherchez des pays...", "noCountryFound": "Aucun pays trouvé.", "siteSelectionDescription": "Ce site fournira la connectivité à la cible.", "resourceType": "Type de ressource", "resourceTypeDescription": "Déterminer comment accéder à la ressource", "resourceHTTPSSettings": "Paramètres HTTPS", "resourceHTTPSSettingsDescription": "Configurer comment la ressource sera accédée via HTTPS", "domainType": "Type de domaine", "subdomain": "Sous-domaine", "baseDomain": "Domaine racine", "subdomnainDescription": "Le sous-domaine où la ressource sera accessible.", "resourceRawSettings": "Paramètres TCP/UDP", "resourceRawSettingsDescription": "Configurer comment la ressource sera accédée via TCP/UDP", "protocol": "Protocole", "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", "resourceAddEntrypoints": "Traefik: Ajouter des points d'entrée", "resourceExposePorts": "Gerbil: Exposer des ports dans Docker Compose", "resourceLearnRaw": "Apprenez à configurer les ressources TCP/UDP", "resourceBack": "Retour aux ressources", "resourceGoTo": "Aller à la ressource", "resourceDelete": "Supprimer la ressource", "resourceDeleteConfirm": "Confirmer la suppression de la ressource", "visibility": "Visibilité", "enabled": "Activé", "disabled": "Désactivé", "general": "Généraux", "generalSettings": "Paramètres généraux", "proxy": "Proxy", "internal": "Interne", "rules": "Règles", "resourceSettingDescription": "Configurer les paramètres de la ressource", "resourceSetting": "Réglages de {resourceName}", "alwaysAllow": "Outrepasser l'authentification", "alwaysDeny": "Bloquer l'accès", "passToAuth": "Passer à l'authentification", "orgSettingsDescription": "Configurer les paramètres de l'organisation", "orgGeneralSettings": "Paramètres de l'organisation", "orgGeneralSettingsDescription": "Gérer les détails et la configuration de l'organisation", "saveGeneralSettings": "Enregistrer les paramètres généraux", "saveSettings": "Enregistrer les paramètres", "orgDangerZone": "Zone dangereuse", "orgDangerZoneDescription": "Une fois cette organisation supprimée, elle ne pourra plus être restaurée. Faites attention.", "orgDelete": "Supprimer l'organisation", "orgDeleteConfirm": "Confirmer la suppression de l'organisation", "orgMessageRemove": "Cette action est irréversible et supprimera toutes les données associées.", "orgMessageConfirm": "Pour confirmer, veuillez saisir le nom de l'organisation ci-dessous.", "orgQuestionRemove": "Êtes-vous sûr de vouloir supprimer l'organisation ?", "orgUpdated": "Organisation mise à jour", "orgUpdatedDescription": "L'organisation a été mise à jour.", "orgErrorUpdate": "Échec de la mise à jour de l'organisation", "orgErrorUpdateMessage": "Une erreur s'est produite lors de la mise à jour de l'organisation.", "orgErrorFetch": "Impossible de récupérer les organisations", "orgErrorFetchMessage": "Une erreur s'est produite lors de la récupération des organisations", "orgErrorDelete": "Échec de la suppression de l'organisation", "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", "accessUsersDescription": "Inviter et gérer les utilisateurs ayant accès à cette organisation", "accessUsersSearch": "Chercher des utilisateurs...", "accessUserCreate": "Créer un utilisateur", "accessUserRemove": "Supprimer un utilisateur", "username": "Nom d'utilisateur", "identityProvider": "Fournisseur d'identité", "role": "Rôle", "nameRequired": "Le nom est requis", "accessRolesManage": "Gérer les rôles", "accessRolesDescription": "Créer et gérer des rôles pour les utilisateurs de l'organisation", "accessRolesSearch": "Chercher des rôles...", "accessRolesAdd": "Ajouter un rôle", "accessRoleDelete": "Supprimer le rôle", "accessApprovalsManage": "Gérer les approbations", "accessApprovalsDescription": "Voir et gérer les approbations en attente pour accéder à cette organisation", "description": "Libellé", "inviteTitle": "Invitations actives", "inviteDescription": "Gérer les invitations des autres utilisateurs à rejoindre l'organisation", "inviteSearch": "Rechercher des invitations...", "minutes": "Minutes", "hours": "Heures", "days": "Jours", "weeks": "Semaines", "months": "Mois", "years": "Années", "day": "{count, plural, one {# jour} other {# jours}}", "apiKeysTitle": "Informations sur la clé d'API", "apiKeysConfirmCopy2": "Vous devez confirmer que vous avez copié la clé d'API.", "apiKeysErrorCreate": "Erreur lors de la création de la clé API", "apiKeysErrorSetPermission": "Erreur lors de la définition des permissions", "apiKeysCreate": "Générer une clé d'API", "apiKeysCreateDescription": "Générer une nouvelle clé API pour l'organisation", "apiKeysGeneralSettings": "Permissions", "apiKeysGeneralSettingsDescription": "Déterminez ce que cette clé d\"API peut faire", "apiKeysList": "Nouvelle clé API", "apiKeysSave": "Enregistrer la clé API", "apiKeysSaveDescription": "Vous ne pourrez la voir qu'une seule fois. Assurez-vous de la copier dans un endroit sécurisé.", "apiKeysInfo": "La clé API est :", "apiKeysConfirmCopy": "J'ai copié la clé d\"API", "generate": "Générer", "done": "Terminé", "apiKeysSeeAll": "Voir toutes les clés d\"API", "apiKeysPermissionsErrorLoadingActions": "Erreur lors du chargement des actions de la clé d\"API", "apiKeysPermissionsErrorUpdate": "Erreur lors de la définition des permissions", "apiKeysPermissionsUpdated": "Permissions mises à jour", "apiKeysPermissionsUpdatedDescription": "Les permissions ont été mises à jour.", "apiKeysPermissionsGeneralSettings": "Permissions", "apiKeysPermissionsGeneralSettingsDescription": "Déterminez ce que cette clé d'API peut faire", "apiKeysPermissionsSave": "Enregistrer les permissions", "apiKeysPermissionsTitle": "Permissions", "apiKeys": "Clés d'API", "searchApiKeys": "Rechercher des clés d'API...", "apiKeysAdd": "Générer une clé d'API", "apiKeysErrorDelete": "Erreur lors de la suppression de la clé d'API", "apiKeysErrorDeleteMessage": "Erreur lors de la suppression de la clé d'API", "apiKeysQuestionRemove": "Êtes-vous sûr de vouloir supprimer la clé d'API de l'organisation ?", "apiKeysMessageRemove": "Une fois supprimée, la clé d'API ne pourra plus être utilisée.", "apiKeysDeleteConfirm": "Confirmer la suppression de la clé d'API", "apiKeysDelete": "Supprimer la clé d'API", "apiKeysManage": "Gérer les clés d'API", "apiKeysDescription": "Les clés d'API sont utilisées pour s'authentifier avec l'API d'intégration", "apiKeysSettings": "Paramètres de {apiKeyName}", "userTitle": "Gérer tous les utilisateurs", "userDescription": "Voir et gérer tous les utilisateurs du système", "userAbount": "À propos de la gestion des utilisateurs", "userAbountDescription": "Cette table affiche tous les objets utilisateur root du système. Chaque utilisateur peut appartenir à plusieurs organisations. La suppression d'un utilisateur d'une organisation ne supprime pas son objet utilisateur root - il restera dans le système. Pour supprimer complètement un utilisateur du système, vous devez supprimer son objet utilisateur root en utilisant l'action de suppression dans cette table.", "userServer": "Utilisateurs du serveur", "userSearch": "Rechercher des utilisateurs du serveur...", "userErrorDelete": "Erreur lors de la suppression de l'utilisateur", "userDeleteConfirm": "Confirmer la suppression de l'utilisateur", "userDeleteServer": "Supprimer l'utilisateur du serveur", "userMessageRemove": "L'utilisateur sera retiré de toutes les organisations et sera complètement retiré du serveur.", "userQuestionRemove": "Êtes-vous sûr de vouloir supprimer définitivement l'utilisateur du serveur?", "licenseKey": "Clé de licence", "valid": "Valide", "numberOfSites": "Nombre de nœuds", "licenseKeySearch": "Rechercher des clés de licence...", "licenseKeyAdd": "Ajouter une clé de licence", "type": "Type", "licenseKeyRequired": "La clé de licence est requise", "licenseTermsAgree": "Vous devez accepter les conditions de licence", "licenseErrorKeyLoad": "Impossible de charger les clés de licence", "licenseErrorKeyLoadDescription": "Une erreur s'est produite lors du chargement des clés de licence.", "licenseErrorKeyDelete": "Échec de la suppression de la clé de licence", "licenseErrorKeyDeleteDescription": "Une erreur s'est produite lors de la suppression de la clé de licence.", "licenseKeyDeleted": "Clé de licence supprimée", "licenseKeyDeletedDescription": "La clé de licence a été supprimée.", "licenseErrorKeyActivate": "Échec de l'activation de la clé de licence", "licenseErrorKeyActivateDescription": "Une erreur s'est produite lors de l'activation de la clé de licence.", "licenseAbout": "À propos de la licence", "communityEdition": "Edition Communautaire", "licenseAboutDescription": "Ceci est destiné aux entreprises qui utilisent Pangolin dans un environnement commercial. Si vous utilisez Pangolin pour un usage personnel, vous pouvez ignorer cette section.", "licenseKeyActivated": "Clé de licence activée", "licenseKeyActivatedDescription": "La clé de licence a été activée avec succès.", "licenseErrorKeyRecheck": "Impossible de revérifier les clés de licence", "licenseErrorKeyRecheckDescription": "Une erreur s'est produite lors de la revérification des clés de licence.", "licenseErrorKeyRechecked": "Clés de licence revérifiées", "licenseErrorKeyRecheckedDescription": "Toutes les clés de licence ont été revérifiées", "licenseActivateKey": "Activer la clé de licence", "licenseActivateKeyDescription": "Entrez une clé de licence pour l'activer.", "licenseActivate": "Activer la licence", "licenseAgreement": "En cochant cette case, vous confirmez avoir lu et accepté les conditions de licence correspondant au niveau associé à votre clé de licence.", "fossorialLicense": "Voir les conditions de licence commerciale et d'abonnement Fossorial", "licenseMessageRemove": "Cela supprimera la clé de licence et toutes les autorisations qui lui sont associées.", "licenseMessageConfirm": "Pour confirmer, veuillez saisir la clé de licence ci-dessous.", "licenseQuestionRemove": "Êtes-vous sûr de vouloir supprimer la clé de licence ?", "licenseKeyDelete": "Supprimer la clé de licence", "licenseKeyDeleteConfirm": "Confirmer la suppression de la clé de licence", "licenseTitle": "Gérer le statut de la licence", "licenseTitleDescription": "Voir et gérer les clés de licence dans le système", "licenseHost": "Licence Hôte", "licenseHostDescription": "Gérer la clé de licence principale de l'hôte.", "licensedNot": "Pas de licence", "hostId": "ID de l'hôte", "licenseReckeckAll": "Revérifier toutes les clés", "licenseSiteUsage": "Utilisation des sites", "licenseSiteUsageDecsription": "Voir le nombre de sites utilisant cette licence.", "licenseNoSiteLimit": "Il n'y a pas de limite sur le nombre de sites utilisant un hôte non autorisé.", "licensePurchase": "Acheter une licence", "licensePurchaseSites": "Acheter des sites supplémentaires", "licenseSitesUsedMax": "{usedSites} des {maxSites} sites utilisés", "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} dans le système.", "licensePurchaseDescription": "Choisissez le nombre de sites que vous voulez {selectedMode, select, license {achetez une licence. Vous pouvez toujours ajouter plus de sites plus tard.} other {ajouter à votre licence existante.}}", "licenseFee": "Frais de licence", "licensePriceSite": "Prix par site", "total": "Total", "licenseContinuePayment": "Continuer vers le paiement", "pricingPage": "page de tarification", "pricingPortal": "Voir le portail d'achat", "licensePricingPage": "Pour les prix et les remises les plus récentes, veuillez visiter le ", "invite": "Invitations", "inviteRegenerate": "Régénérer l'invitation", "inviteRegenerateDescription": "Révoquer l'invitation précédente et en créer une nouvelle", "inviteRemove": "Supprimer l'invitation", "inviteRemoveError": "Échec de la suppression de l'invitation", "inviteRemoveErrorDescription": "Une erreur s'est produite lors de la suppression de l'invitation.", "inviteRemoved": "Invitation supprimée", "inviteRemovedDescription": "L'invitation pour {email} a été supprimée.", "inviteQuestionRemove": "Êtes-vous sûr de vouloir supprimer l'invitation?", "inviteMessageRemove": "Une fois supprimée, cette invitation ne sera plus valide. Vous pourrez toujours réinviter l'utilisateur plus tard.", "inviteMessageConfirm": "Pour confirmer, veuillez saisir l'adresse e-mail de l'invitation ci-dessous.", "inviteQuestionRegenerate": "Êtes-vous sûr de vouloir régénérer l'invitation pour {email}? Cela révoquera l'invitation précédente.", "inviteRemoveConfirm": "Confirmer la suppression de l'invitation", "inviteRegenerated": "Invitation régénérée", "inviteSent": "Une nouvelle invitation a été envoyée à {email}.", "inviteSentEmail": "Envoyer une notification par e-mail à l'utilisateur", "inviteGenerate": "Une nouvelle invitation a été générée pour {email}.", "inviteDuplicateError": "Invitation en double", "inviteDuplicateErrorDescription": "Une invitation pour cet utilisateur existe déjà.", "inviteRateLimitError": "Limite de taux dépassée", "inviteRateLimitErrorDescription": "Vous avez dépassé la limite de 3 régénérations par heure. Veuillez réessayer plus tard.", "inviteRegenerateError": "Échec de la régénération de l'invitation", "inviteRegenerateErrorDescription": "Une erreur s'est produite lors de la régénération de l'invitation.", "inviteValidityPeriod": "Période de validité", "inviteValidityPeriodSelect": "Sélectionner la période de validité", "inviteRegenerateMessage": "L'invitation a été régénérée. L'utilisateur doit accéder au lien ci-dessous pour accepter l'invitation.", "inviteRegenerateButton": "Régénérer", "expiresAt": "Expire le", "accessRoleUnknown": "Rôle inconnu", "placeholder": "Espace réservé", "userErrorOrgRemove": "Échec de la suppression de l'utilisateur", "userErrorOrgRemoveDescription": "Une erreur s'est produite lors de la suppression de l'utilisateur.", "userOrgRemoved": "Utilisateur supprimé", "userOrgRemovedDescription": "L'utilisateur {email} a été retiré de l'organisation.", "userQuestionOrgRemove": "Êtes-vous sûr de vouloir supprimer cet utilisateur de l'organisation ?", "userMessageOrgRemove": "Une fois retiré, cet utilisateur n'aura plus accès à l'organisation. Vous pouvez toujours le réinviter plus tard, mais il devra accepter l'invitation à nouveau.", "userRemoveOrgConfirm": "Confirmer la suppression de l'utilisateur", "userRemoveOrg": "Retirer l'utilisateur de l'organisation", "users": "Utilisateurs", "accessRoleMember": "Membre", "accessRoleOwner": "Propriétaire", "userConfirmed": "Confirmé", "idpNameInternal": "Interne", "emailInvalid": "Adresse e-mail invalide", "inviteValidityDuration": "Veuillez sélectionner une durée", "accessRoleSelectPlease": "Veuillez sélectionner un rôle", "usernameRequired": "Le nom d'utilisateur est requis", "idpSelectPlease": "Veuillez sélectionner un fournisseur d'identité", "idpGenericOidc": "Fournisseur OAuth2/OIDC générique.", "accessRoleErrorFetch": "Échec de la récupération des rôles", "accessRoleErrorFetchDescription": "Une erreur s'est produite lors de la récupération des rôles", "idpErrorFetch": "Échec de la récupération des fournisseurs d'identité", "idpErrorFetchDescription": "Une erreur s'est produite lors de la récupération des fournisseurs d'identité", "userErrorExists": "L'utilisateur existe déjà", "userErrorExistsDescription": "Cet utilisateur est déjà membre de l'organisation.", "inviteError": "Échec de l'invitation de l'utilisateur", "inviteErrorDescription": "Une erreur s'est produite lors de l'invitation de l'utilisateur", "userInvited": "Utilisateur invité", "userInvitedDescription": "L'utilisateur a été invité avec succès.", "userErrorCreate": "Échec de la création de l'utilisateur", "userErrorCreateDescription": "Une erreur s'est produite lors de la création de l'utilisateur", "userCreated": "Utilisateur créé", "userCreatedDescription": "L'utilisateur a été créé avec succès.", "userTypeInternal": "Utilisateur interne", "userTypeInternalDescription": "Invitez un utilisateur à rejoindre l'organisation directement.", "userTypeExternal": "Utilisateur externe", "userTypeExternalDescription": "Créer un utilisateur avec un fournisseur d'identité externe.", "accessUserCreateDescription": "Suivez les étapes ci-dessous pour créer un nouvel utilisateur", "userSeeAll": "Voir tous les utilisateurs", "userTypeTitle": "Type d'utilisateur", "userTypeDescription": "Déterminez comment vous voulez créer l'utilisateur", "userSettings": "Informations utilisateur", "userSettingsDescription": "Entrez les détails du nouvel utilisateur", "inviteEmailSent": "Envoyer un e-mail d'invitation à l'utilisateur", "inviteValid": "Valide pour", "selectDuration": "Sélectionner la durée", "selectResource": "Sélectionner une ressource", "filterByResource": "Filtrer par ressource", "selectApprovalState": "Sélectionnez l'État d'Approbation", "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é", "deniedApproval": "Approbation refusée", "all": "Tous", "deny": "Refuser", "viewDetails": "Voir les détails", "requestingNewDeviceApproval": "a demandé un nouvel appareil", "resetFilters": "Réinitialiser les filtres", "totalBlocked": "Demandes bloquées par le Pangolin", "totalRequests": "Total des demandes", "requestsByCountry": "Requêtes par pays", "requestsByDay": "Requêtes par jour", "blocked": "Bloqué", "allowed": "Autorisé", "topCountries": "Meilleurs pays", "accessRoleSelect": "Sélectionner un rôle", "inviteEmailSentDescription": "Un e-mail a été envoyé à l'utilisateur avec le lien d'accès ci-dessous. Ils doivent accéder au lien pour accepter l'invitation.", "inviteSentDescription": "L'utilisateur a été invité. Ils doivent accéder au lien ci-dessous pour accepter l'invitation.", "inviteExpiresIn": "L'invitation expirera dans {days, plural, one {# jour} other {# jours}}.", "idpTitle": "Informations générales", "idpSelect": "Sélectionnez le fournisseur d'identité pour l'utilisateur externe", "idpNotConfigured": "Aucun fournisseur d'identité n'est configuré. Veuillez configurer un fournisseur d'identité avant de créer des utilisateurs externes.", "usernameUniq": "Ceci doit correspondre au nom d'utilisateur unique qui existe dans le fournisseur d'identité sélectionné.", "emailOptional": "E-mail (Optionnel)", "nameOptional": "Nom (Optionnel)", "accessControls": "Contrôles d'accès", "userDescription2": "Gérer les paramètres de cet utilisateur", "accessRoleErrorAdd": "Échec de l'ajout de l'utilisateur au rôle", "accessRoleErrorAddDescription": "Une erreur s'est produite lors de l'ajout de l'utilisateur au rôle.", "userSaved": "Utilisateur enregistré", "userSavedDescription": "L'utilisateur a été mis à jour.", "autoProvisioned": "Auto-provisionné", "autoProvisionedDescription": "Permettre à cet utilisateur d'être géré automatiquement par le fournisseur d'identité", "accessControlsDescription": "Gérer ce que cet utilisateur peut accéder et faire dans l'organisation", "accessControlsSubmit": "Enregistrer les contrôles d'accès", "roles": "Rôles", "accessUsersRoles": "Gérer les utilisateurs et les rôles", "accessUsersRolesDescription": "Invitez des utilisateurs et ajoutez-les aux rôles pour gérer l'accès à l'organisation", "key": "Clé", "createdAt": "Créé le", "proxyErrorInvalidHeader": "Valeur d'en-tête Host personnalisée invalide. Utilisez le format de nom de domaine, ou laissez vide pour désactiver l'en-tête Host personnalisé.", "proxyErrorTls": "Nom de serveur TLS invalide. Utilisez le format de nom de domaine, ou laissez vide pour supprimer le nom de serveur TLS.", "proxyEnableSSL": "Activer SSL", "proxyEnableSSLDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers les cibles.", "target": "Cible", "configureTarget": "Configurer les cibles", "targetErrorFetch": "Échec de la récupération des cibles", "targetErrorFetchDescription": "Une erreur s'est produite lors de la récupération des cibles", "siteErrorFetch": "Échec de la récupération de la ressource", "siteErrorFetchDescription": "Une erreur s'est produite lors de la récupération de la ressource", "targetErrorDuplicate": "Cible en double", "targetErrorDuplicateDescription": "Une cible avec ces paramètres existe déjà", "targetWireGuardErrorInvalidIp": "IP cible invalide", "targetWireGuardErrorInvalidIpDescription": "L'IP cible doit être dans le sous-réseau du site", "targetsUpdated": "Cibles mises à jour", "targetsUpdatedDescription": "Cibles et paramètres mis à jour avec succès", "targetsErrorUpdate": "Échec de la mise à jour des cibles", "targetsErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour des cibles", "targetTlsUpdate": "Paramètres TLS mis à jour", "targetTlsUpdateDescription": "Les paramètres TLS ont été mis à jour avec succès", "targetErrorTlsUpdate": "Échec de la mise à jour des paramètres TLS", "targetErrorTlsUpdateDescription": "Une erreur s'est produite lors de la mise à jour des paramètres TLS", "proxyUpdated": "Paramètres du proxy mis à jour", "proxyUpdatedDescription": "Les paramètres du proxy ont été mis à jour avec succès", "proxyErrorUpdate": "Échec de la mise à jour des paramètres du proxy", "proxyErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour des paramètres du proxy", "targetAddr": "Hôte", "targetPort": "Port", "targetProtocol": "Protocole", "targetTlsSettings": "Configuration sécurisée de connexion", "targetTlsSettingsDescription": "Configurer les paramètres SSL/TLS pour la ressource", "targetTlsSettingsAdvanced": "Paramètres TLS avancés", "targetTlsSni": "Nom du serveur TLS", "targetTlsSniDescription": "Le nom de serveur TLS à utiliser pour SNI. Laissez vide pour utiliser la valeur par défaut.", "targetTlsSubmit": "Enregistrer les paramètres", "targets": "Configuration des cibles", "targetsDescription": "Définir des cibles pour acheminer le trafic vers les services backend", "targetStickySessions": "Activer les sessions persistantes", "targetStickySessionsDescription": "Maintenir les connexions sur la même cible backend pendant toute leur session.", "methodSelect": "Sélectionner la méthode", "targetSubmit": "Ajouter une cible", "targetNoOne": "Cette ressource n'a aucune cible. Ajoutez une cible pour configurer où envoyer des requêtes à l'arrière-plan.", "targetNoOneDescription": "L'ajout de plus d'une cible ci-dessus activera l'équilibrage de charge.", "targetsSubmit": "Enregistrer les cibles", "addTarget": "Ajouter une cible", "targetErrorInvalidIp": "Adresse IP invalide", "targetErrorInvalidIpDescription": "Veuillez entrer une adresse IP ou un nom d'hôte valide", "targetErrorInvalidPort": "Port invalide", "targetErrorInvalidPortDescription": "Veuillez entrer un numéro de port valide", "targetErrorNoSite": "Aucun site sélectionné", "targetErrorNoSiteDescription": "Veuillez sélectionner un site pour la cible", "targetCreated": "Cible créée", "targetCreatedDescription": "La cible a été créée avec succès", "targetErrorCreate": "Impossible de créer la cible", "targetErrorCreateDescription": "Une erreur s'est produite lors de la création de la cible", "tlsServerName": "Nom du serveur TLS", "tlsServerNameDescription": "Le nom du serveur TLS à utiliser pour la SNI", "save": "Enregistrer", "proxyAdditional": "Paramètres de proxy supplémentaires", "proxyAdditionalDescription": "Configurer comment la ressource gère les paramètres du proxy", "proxyCustomHeader": "En-tête Host personnalisé", "proxyCustomHeaderDescription": "L'en-tête host à définir lors du proxy des requêtes. Laissez vide pour utiliser la valeur par défaut.", "proxyAdditionalSubmit": "Enregistrer les paramètres de proxy", "subnetMaskErrorInvalid": "Masque de sous-réseau invalide. Doit être entre 0 et 32.", "ipAddressErrorInvalidFormat": "Format d'adresse IP invalide", "ipAddressErrorInvalidOctet": "Octet d'adresse IP invalide", "path": "Chemin", "matchPath": "Chemin de correspondance", "ipAddressRange": "Plage IP", "rulesErrorFetch": "Échec de la récupération des règles", "rulesErrorFetchDescription": "Une erreur s'est produite lors de la récupération des règles", "rulesErrorDuplicate": "Règle en double", "rulesErrorDuplicateDescription": "Une règle avec ces paramètres existe déjà", "rulesErrorInvalidIpAddressRange": "CIDR invalide", "rulesErrorInvalidIpAddressRangeDescription": "Veuillez entrer une valeur CIDR valide", "rulesErrorInvalidUrl": "Chemin URL invalide", "rulesErrorInvalidUrlDescription": "Veuillez entrer un chemin URL valide", "rulesErrorInvalidIpAddress": "IP invalide", "rulesErrorInvalidIpAddressDescription": "Veuillez entrer une adresse IP valide", "rulesErrorUpdate": "Échec de la mise à jour des règles", "rulesErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour des règles", "rulesUpdated": "Activer les règles", "rulesUpdatedDescription": "L'évaluation des règles a été mise à jour", "rulesMatchIpAddressRangeDescription": "Entrez une adresse au format CIDR (ex: 103.21.244.0/22)", "rulesMatchIpAddress": "Entrez une adresse IP (ex: 103.21.244.12)", "rulesMatchUrl": "Entrez un chemin URL ou un motif (ex: /api/v1/todos ou /api/v1/*)", "rulesErrorInvalidPriority": "Priorité invalide", "rulesErrorInvalidPriorityDescription": "Veuillez entrer une priorité valide", "rulesErrorDuplicatePriority": "Priorités en double", "rulesErrorDuplicatePriorityDescription": "Veuillez entrer des priorités uniques", "ruleUpdated": "Règles mises à jour", "ruleUpdatedDescription": "Règles mises à jour avec succès", "ruleErrorUpdate": "L'opération a échoué", "ruleErrorUpdateDescription": "Une erreur s'est produite lors de l'enregistrement", "rulesPriority": "Priorité", "rulesAction": "Action", "rulesMatchType": "Type de correspondance", "value": "Valeur", "rulesAbout": "À propos des règles", "rulesAboutDescription": "Les règles vous permettent de contrôler l'accès à la ressource en fonction d'un ensemble de critères. Vous pouvez créer des règles pour autoriser ou refuser l'accès en fonction de l'adresse IP ou du chemin d'URL.", "rulesActions": "Actions", "rulesActionAlwaysAllow": "Toujours autoriser : Contourner toutes les méthodes d'authentification", "rulesActionAlwaysDeny": "Toujours refuser : Bloquer toutes les requêtes ; aucune authentification ne peut être tentée", "rulesActionPassToAuth": "Passer à l'authentification : Autoriser les méthodes d'authentification à être tentées", "rulesMatchCriteria": "Critères de correspondance", "rulesMatchCriteriaIpAddress": "Correspondre à une adresse IP spécifique", "rulesMatchCriteriaIpAddressRange": "Correspondre à une plage d'adresses IP en notation CIDR", "rulesMatchCriteriaUrl": "Correspondre à un chemin URL ou un motif", "rulesEnable": "Activer les règles", "rulesEnableDescription": "Activer ou désactiver l'évaluation des règles pour cette ressource", "rulesResource": "Configuration des règles de ressource", "rulesResourceDescription": "Configurer les règles pour contrôler l'accès à la ressource", "ruleSubmit": "Ajouter une règle", "rulesNoOne": "Aucune règle. Ajoutez une règle en utilisant le formulaire.", "rulesOrder": "Les règles sont évaluées par priorité dans l'ordre croissant.", "rulesSubmit": "Enregistrer les règles", "resourceErrorCreate": "Erreur lors de la création de la ressource", "resourceErrorCreateDescription": "Une erreur s'est produite lors de la création de la ressource", "resourceErrorCreateMessage": "Erreur lors de la création de la ressource :", "resourceErrorCreateMessageDescription": "Une erreur inattendue s'est produite", "sitesErrorFetch": "Erreur lors de la récupération des sites", "sitesErrorFetchDescription": "Une erreur s'est produite lors de la récupération des sites", "domainsErrorFetch": "Erreur lors de la récupération des domaines", "domainsErrorFetchDescription": "Une erreur s'est produite lors de la récupération des domaines", "none": "Aucun", "unknown": "Inconnu", "resources": "Ressources", "resourcesDescription": "Les ressources sont des proxy pour les applications exécutées sur le réseau privé. Créez une ressource pour tous les services HTTP/HTTPS ou TCP/UDP bruts sur votre réseau privé. Chaque ressource doit être connectée à un site pour permettre une connectivité privée et sécurisée via un tunnel WireGuard chiffré.", "resourcesWireGuardConnect": "Connectivité sécurisée avec chiffrement WireGuard", "resourcesMultipleAuthenticationMethods": "Configurer plusieurs méthodes d'authentification", "resourcesUsersRolesAccess": "Contrôle d'accès basé sur les utilisateurs et les rôles", "resourcesErrorUpdate": "Échec de la bascule de la ressource", "resourcesErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour de la ressource", "access": "Accès", "accessControl": "Contrôle d'accès", "shareLink": "Lien de partage {resource}", "resourceSelect": "Sélectionner une ressource", "shareLinks": "Liens de partage", "share": "Liens partageables", "shareDescription2": "Créez des liens partageables vers des ressources. Les liens fournissent un accès temporaire ou illimité à votre ressource. Vous pouvez configurer la durée d'expiration du lien lorsque vous en créez un.", "shareEasyCreate": "Facile à créer et à partager", "shareConfigurableExpirationDuration": "Durée d'expiration configurable", "shareSecureAndRevocable": "Sécurisé et révocable", "nameMin": "Le nom doit comporter au moins {len} caractères.", "nameMax": "Le nom ne doit pas dépasser {len} caractères.", "sitesConfirmCopy": "Veuillez confirmer que vous avez copié la configuration.", "unknownCommand": "Commande inconnue", "newtErrorFetchReleases": "Échec de la récupération des informations de version : {err}", "newtErrorFetchLatest": "Erreur lors de la récupération de la dernière version : {err}", "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Secrète", "architecture": "Architecture", "sites": "Nœuds", "siteWgAnyClients": "Utilisez n'importe quel client WireGuard pour vous connecter. Vous devrez adresser des ressources internes en utilisant l'adresse IP du pair.", "siteWgCompatibleAllClients": "Compatible avec tous les clients WireGuard", "siteWgManualConfigurationRequired": "Configuration manuelle requise", "userErrorNotAdminOrOwner": "L'utilisateur n'est pas un administrateur ou un propriétaire", "pangolinSettings": "Paramètres - Pangolin", "accessRoleYour": "Votre rôle :", "accessRoleSelect2": "Sélectionner les rôles", "accessUserSelect": "Sélectionner les utilisateurs", "otpEmailEnter": "Entrer un e-mail", "otpEmailEnterDescription": "Appuyez sur Entrée pour ajouter un e-mail après l'avoir saisi dans le champ.", "otpEmailErrorInvalid": "Adresse e-mail invalide. Le caractère générique (*) doit être la partie locale entière.", "otpEmailSmtpRequired": "SMTP requis", "otpEmailSmtpRequiredDescription": "Le SMTP doit être activé sur le serveur pour utiliser l'authentification par mot de passe à usage unique.", "otpEmailTitle": "Mots de passe à usage unique", "otpEmailTitleDescription": "Exiger une authentification par e-mail pour l'accès aux ressources", "otpEmailWhitelist": "Liste blanche des e-mails", "otpEmailWhitelistList": "E-mails sur liste blanche", "otpEmailWhitelistListDescription": "Seuls les utilisateurs avec ces adresses e-mail pourront accéder à cette ressource. Ils devront saisir un mot de passe à usage unique envoyé à leur e-mail. Les caractères génériques (*@example.com) peuvent être utilisés pour autoriser n'importe quelle adresse e-mail d'un domaine.", "otpEmailWhitelistSave": "Enregistrer la liste blanche", "passwordAdd": "Ajouter un mot de passe", "passwordRemove": "Supprimer le mot de passe", "pincodeAdd": "Ajouter un code PIN", "pincodeRemove": "Supprimer le code PIN", "resourceAuthMethods": "Méthodes d'authentification", "resourceAuthMethodsDescriptions": "Permettre l'accès à la ressource via des méthodes d'authentification supplémentaires", "resourceAuthSettingsSave": "Enregistré avec succès", "resourceAuthSettingsSaveDescription": "Les paramètres d'authentification ont été enregistrés", "resourceErrorAuthFetch": "Échec de la récupération des données", "resourceErrorAuthFetchDescription": "Une erreur s'est produite lors de la récupération des données", "resourceErrorPasswordRemove": "Erreur lors de la suppression du mot de passe de la ressource", "resourceErrorPasswordRemoveDescription": "Une erreur s'est produite lors de la suppression du mot de passe de la ressource", "resourceErrorPasswordSetup": "Erreur lors de la configuration du mot de passe de la ressource", "resourceErrorPasswordSetupDescription": "Une erreur s'est produite lors de la configuration du mot de passe de la ressource", "resourceErrorPincodeRemove": "Erreur lors de la suppression du code PIN de la ressource", "resourceErrorPincodeRemoveDescription": "Une erreur s'est produite lors de la suppression du code PIN de la ressource", "resourceErrorPincodeSetup": "Erreur lors de la configuration du code PIN de la ressource", "resourceErrorPincodeSetupDescription": "Une erreur s'est produite lors de la configuration du code PIN de la ressource", "resourceErrorUsersRolesSave": "Échec de la définition des rôles", "resourceErrorUsersRolesSaveDescription": "Une erreur s'est produite lors de la définition des rôles", "resourceErrorWhitelistSave": "Échec de l'enregistrement de la liste blanche", "resourceErrorWhitelistSaveDescription": "Une erreur s'est produite lors de l'enregistrement de la liste blanche", "resourcePasswordSubmit": "Activer la protection par mot de passe", "resourcePasswordProtection": "Protection par mot de passe {status}", "resourcePasswordRemove": "Mot de passe de la ressource supprimé", "resourcePasswordRemoveDescription": "Le mot de passe de la ressource a été supprimé avec succès", "resourcePasswordSetup": "Mot de passe de la ressource défini", "resourcePasswordSetupDescription": "Le mot de passe de la ressource a été défini avec succès", "resourcePasswordSetupTitle": "Définir le mot de passe", "resourcePasswordSetupTitleDescription": "Définir un mot de passe pour protéger cette ressource", "resourcePincode": "Code PIN", "resourcePincodeSubmit": "Activer la protection par code PIN", "resourcePincodeProtection": "Protection par code PIN {status}", "resourcePincodeRemove": "Code PIN de la ressource supprimé", "resourcePincodeRemoveDescription": "Le code PIN de la ressource a été supprimé avec succès", "resourcePincodeSetup": "Code PIN de la ressource défini", "resourcePincodeSetupDescription": "Le code PIN de la ressource a été défini avec succès", "resourcePincodeSetupTitle": "Définir le code PIN", "resourcePincodeSetupTitleDescription": "Définir un code PIN pour protéger cette ressource", "resourceRoleDescription": "Les administrateurs peuvent toujours accéder à cette ressource.", "resourceUsersRoles": "Contrôles d'accès", "resourceUsersRolesDescription": "Configurer quels utilisateurs et rôles peuvent visiter cette ressource", "resourceUsersRolesSubmit": "Enregistrer les contrôles d'accès", "resourceWhitelistSave": "Enregistré avec succès", "resourceWhitelistSaveDescription": "Les paramètres de la liste blanche ont été enregistrés", "ssoUse": "Utiliser la SSO de la plateforme", "ssoUseDescription": "Les utilisateurs existants n'auront à se connecter qu'une seule fois pour toutes les ressources qui ont cette option activée.", "proxyErrorInvalidPort": "Numéro de port invalide", "subdomainErrorInvalid": "Sous-domaine invalide", "domainErrorFetch": "Erreur lors de la récupération des domaines", "domainErrorFetchDescription": "Une erreur s'est produite lors de la récupération des domaines", "resourceErrorUpdate": "Échec de la mise à jour de la ressource", "resourceErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour de la ressource", "resourceUpdated": "Ressource mise à jour", "resourceUpdatedDescription": "La ressource a été mise à jour avec succès", "resourceErrorTransfer": "Échec du transfert de la ressource", "resourceErrorTransferDescription": "Une erreur s'est produite lors du transfert de la ressource", "resourceTransferred": "Ressource transférée", "resourceTransferredDescription": "La ressource a été transférée avec succès", "resourceErrorToggle": "Échec de la modification de l'état de la ressource", "resourceErrorToggleDescription": "Une erreur s'est produite lors de la mise à jour de la ressource", "resourceVisibilityTitle": "Visibilité", "resourceVisibilityTitleDescription": "Activer ou désactiver complètement la visibilité de la ressource", "resourceGeneral": "Paramètres généraux", "resourceGeneralDescription": "Configurer les paramètres généraux de cette ressource", "resourceEnable": "Activer la ressource", "resourceTransfer": "Transférer la ressource", "resourceTransferDescription": "Transférer cette ressource vers un autre site", "resourceTransferSubmit": "Transférer la ressource", "siteDestination": "Site de destination", "searchSites": "Rechercher des sites", "countries": "Pays", "accessRoleCreate": "Créer un rôle", "accessRoleCreateDescription": "Créer un nouveau rôle pour regrouper les utilisateurs et gérer leurs permissions.", "accessRoleEdit": "Modifier le rôle", "accessRoleEditDescription": "Modifier les informations du rôle.", "accessRoleCreateSubmit": "Créer un rôle", "accessRoleCreated": "Rôle créé", "accessRoleCreatedDescription": "Le rôle a été créé avec succès.", "accessRoleErrorCreate": "Échec de la création du rôle", "accessRoleErrorCreateDescription": "Une erreur s'est produite lors de la création du rôle.", "accessRoleUpdateSubmit": "Mettre à jour un rôle", "accessRoleUpdated": "Rôle mis à jour", "accessRoleUpdatedDescription": "Le rôle a été mis à jour avec succès.", "accessApprovalUpdated": "Approbation traitée", "accessApprovalApprovedDescription": "Définir la décision de la demande d'approbation à approuver.", "accessApprovalDeniedDescription": "Définir la décision de la demande d'approbation comme refusée.", "accessRoleErrorUpdate": "Impossible de mettre à jour le rôle", "accessRoleErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour du rôle.", "accessApprovalErrorUpdate": "Impossible de traiter l'approbation", "accessApprovalErrorUpdateDescription": "Une erreur s'est produite lors du traitement de l'approbation.", "accessRoleErrorNewRequired": "Un nouveau rôle est requis", "accessRoleErrorRemove": "Échec de la suppression du rôle", "accessRoleErrorRemoveDescription": "Une erreur s'est produite lors de la suppression du rôle.", "accessRoleName": "Nom du rôle", "accessRoleQuestionRemove": "Vous êtes sur le point de supprimer le rôle `{name}`. Vous ne pouvez pas annuler cette action.", "accessRoleRemove": "Supprimer le rôle", "accessRoleRemoveDescription": "Retirer un rôle de l'organisation", "accessRoleRemoveSubmit": "Supprimer le rôle", "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", "licenseTierProfessional": "Licence Professionnelle", "licenseTierEnterprise": "Licence Entreprise", "licenseTierPersonal": "Licence personnelle", "licensed": "Sous licence", "yes": "Oui", "no": "Non", "sitesAdditional": "Sites supplémentaires", "licenseKeys": "Clés de licence", "sitestCountDecrease": "Diminuer le nombre de sites", "sitestCountIncrease": "Augmenter le nombre de sites", "idpManage": "Gérer les fournisseurs d'identité", "idpManageDescription": "Voir et gérer les fournisseurs d'identité dans le système", "idpGlobalModeBanner": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérez les IdPs globaux dans le panneau d'administration . Pour activer les IdPs par organisation, éditez la configuration du serveur et réglez le mode IdP sur org. Voir la documentation. Si vous voulez continuer à utiliser les IdPs globaux et faire disparaître cela des paramètres de l'organisation, définissez explicitement le mode à global dans la configuration.", "idpGlobalModeBannerUpgradeRequired": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérer les IdPs globaux dans le panneau d'administration . Pour utiliser les fournisseurs d'identité par organisation, vous devez passer à l'édition Entreprise.", "idpGlobalModeBannerLicenseRequired": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérer les IdPs globaux dans le panneau d'administration . Pour utiliser les fournisseurs d'identité par organisation, une licence d'entreprise est requise.", "idpDeletedDescription": "Fournisseur d'identité supprimé avec succès", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Êtes-vous sûr de vouloir supprimer définitivement le fournisseur d'identité?", "idpMessageRemove": "Cela supprimera le fournisseur d'identité et toutes les configurations associées. Les utilisateurs qui s'authentifient via ce fournisseur ne pourront plus se connecter.", "idpMessageConfirm": "Pour confirmer, veuillez saisir le nom du fournisseur d'identité ci-dessous.", "idpConfirmDelete": "Confirmer la suppression du fournisseur d'identité", "idpDelete": "Supprimer le fournisseur d'identité", "idp": "Fournisseurs d'identité", "idpSearch": "Rechercher des fournisseurs d'identité...", "idpAdd": "Ajouter un fournisseur d'identité", "idpClientIdRequired": "L'ID client est requis.", "idpClientSecretRequired": "Le secret client est requis.", "idpErrorAuthUrlInvalid": "L'URL d'authentification doit être une URL valide.", "idpErrorTokenUrlInvalid": "L'URL du jeton doit être une URL valide.", "idpPathRequired": "Le chemin d'identification est requis.", "idpScopeRequired": "Les portées sont requises.", "idpOidcDescription": "Configurer un fournisseur d'identité OpenID Connect", "idpCreatedDescription": "Fournisseur d'identité créé avec succès", "idpCreate": "Créer un fournisseur d'identité", "idpCreateDescription": "Configurer un nouveau fournisseur d'identité pour l'authentification des utilisateurs", "idpSeeAll": "Voir tous les fournisseurs d'identité", "idpSettingsDescription": "Configurer les informations de base de votre fournisseur d'identité", "idpDisplayName": "Un nom d'affichage pour ce fournisseur d'identité", "idpAutoProvisionUsers": "Approvisionnement automatique des utilisateurs", "idpAutoProvisionUsersDescription": "Lorsque cette option est activée, les utilisateurs seront automatiquement créés dans le système lors de leur première connexion avec la possibilité de mapper les utilisateurs aux rôles et aux organisations.", "licenseBadge": "EE", "idpType": "Type de fournisseur", "idpTypeDescription": "Sélectionnez le type de fournisseur d'identité que vous souhaitez configurer", "idpOidcConfigure": "Configuration OAuth2/OIDC", "idpOidcConfigureDescription": "Configurer les points de terminaison et les identifiants du fournisseur OAuth2/OIDC", "idpClientId": "ID Client", "idpClientIdDescription": "L'identifiant client OAuth2 du fournisseur d'identité", "idpClientSecret": "Secret Client", "idpClientSecretDescription": "Le secret du client OAuth2 du fournisseur d'identité", "idpAuthUrl": "URL d'autorisation", "idpAuthUrlDescription": "L'URL du point de terminaison d'autorisation OAuth2", "idpTokenUrl": "URL du jeton", "idpTokenUrlDescription": "L'URL du point de terminaison du jeton OAuth2", "idpOidcConfigureAlert": "Information importante", "idpOidcConfigureAlertDescription": "Après avoir créé le fournisseur d'identité, vous devrez configurer l'URL de rappel dans les paramètres du fournisseur d'identité. L'URL de rappel sera fournie après la création réussie.", "idpToken": "Configuration du jeton", "idpTokenDescription": "Configurer comment extraire les informations utilisateur du jeton ID", "idpJmespathAbout": "À propos de JMESPath", "idpJmespathAboutDescription": "Les chemins ci-dessous utilisent la syntaxe JMESPath pour extraire des valeurs du jeton ID.", "idpJmespathAboutDescriptionLink": "En savoir plus sur JMESPath", "idpJmespathLabel": "Chemin d'identification", "idpJmespathLabelDescription": "Le JMESPath vers l'identifiant de l'utilisateur dans le jeton ID", "idpJmespathEmailPathOptional": "Chemin de l'email (Optionnel)", "idpJmespathEmailPathOptionalDescription": "Le JMESPath vers l'email de l'utilisateur dans le jeton ID", "idpJmespathNamePathOptional": "Chemin du nom (Optionnel)", "idpJmespathNamePathOptionalDescription": "Le JMESPath vers le nom de l'utilisateur dans le jeton ID", "idpOidcConfigureScopes": "Portées", "idpOidcConfigureScopesDescription": "Liste des portées OAuth2 à demander, séparées par des espaces", "idpSubmit": "Créer le fournisseur d'identité", "orgPolicies": "Politiques d'organisation", "idpSettings": "Paramètres de {idpName}", "idpCreateSettingsDescription": "Configurer les paramètres du fournisseur d'identité", "roleMapping": "Mappage des rôles", "orgMapping": "Mappage d'organisation", "orgPoliciesSearch": "Rechercher des politiques d'organisation...", "orgPoliciesAdd": "Ajouter une politique d'organisation", "orgRequired": "L'organisation est requise", "error": "Erreur", "success": "Succès", "orgPolicyAddedDescription": "Politique ajoutée avec succès", "orgPolicyUpdatedDescription": "Politique mise à jour avec succès", "orgPolicyDeletedDescription": "Politique supprimée avec succès", "defaultMappingsUpdatedDescription": "Mappages par défaut mis à jour avec succès", "orgPoliciesAbout": "À propos des politiques d'organisation", "orgPoliciesAboutDescription": "Les politiques d'organisation sont utilisées pour contrôler l'accès aux organisations en fonction du jeton ID de l'utilisateur. Vous pouvez spécifier des expressions JMESPath pour extraire les informations de rôle et d'organisation du jeton ID. Pour plus d'informations, voir", "orgPoliciesAboutDescriptionLink": "la documentation", "defaultMappingsOptional": "Mappages par défaut (Optionnel)", "defaultMappingsOptionalDescription": "Les mappages par défaut sont utilisés lorsqu'il n'y a pas de politique d'organisation définie pour une organisation. Vous pouvez spécifier ici les mappages de rôle et d'organisation par défaut à utiliser.", "defaultMappingsRole": "Mappage de rôle par défaut", "defaultMappingsRoleDescription": "JMESPath pour extraire les informations de rôle du jeton ID. Le résultat de cette expression doit renvoyer le nom du rôle tel que défini dans l'organisation sous forme de chaîne.", "defaultMappingsOrg": "Mappage d'organisation par défaut", "defaultMappingsOrgDescription": "JMESPath pour extraire les informations d'organisation du jeton ID. Cette expression doit renvoyer l'ID de l'organisation ou true pour que l'utilisateur soit autorisé à accéder à l'organisation.", "defaultMappingsSubmit": "Enregistrer les mappages par défaut", "orgPoliciesEdit": "Modifier la politique d'organisation", "org": "Organisation", "orgSelect": "Sélectionner une organisation", "orgSearch": "Rechercher une organisation", "orgNotFound": "Aucune organisation trouvée.", "roleMappingPathOptional": "Chemin de mappage des rôles (Optionnel)", "orgMappingPathOptional": "Chemin de mappage d'organisation (Optionnel)", "orgPolicyUpdate": "Mettre à jour la politique", "orgPolicyAdd": "Ajouter une politique", "orgPolicyConfig": "Configurer l'accès pour une organisation", "idpUpdatedDescription": "Fournisseur d'identité mis à jour avec succès", "redirectUrl": "URL de redirection", "orgIdpRedirectUrls": "URL de redirection", "redirectUrlAbout": "À propos de l'URL de redirection", "redirectUrlAboutDescription": "C'est l'URL vers laquelle les utilisateurs seront redirigés après l'authentification. Vous devez configurer cette URL dans les paramètres du fournisseur d'identité.", "pangolinAuth": "Auth - Pangolin", "verificationCodeLengthRequirements": "Votre code de vérification doit comporter 8 caractères.", "errorOccurred": "Une erreur s'est produite", "emailErrorVerify": "Échec de la vérification de l'e-mail :", "emailVerified": "E-mail vérifié avec succès ! Redirection...", "verificationCodeErrorResend": "Échec du renvoi du code de vérification :", "verificationCodeResend": "Code de vérification renvoyé", "verificationCodeResendDescription": "Nous avons renvoyé un code de vérification à votre adresse e-mail. Veuillez vérifier votre boîte de réception.", "emailVerify": "Vérifier l'e-mail", "emailVerifyDescription": "Entrez le code de vérification envoyé à votre adresse e-mail.", "verificationCode": "Code de vérification", "verificationCodeEmailSent": "Nous avons envoyé un code de vérification à votre adresse e-mail.", "submit": "Soumettre", "emailVerifyResendProgress": "Renvoi en cours...", "emailVerifyResend": "Vous n'avez pas reçu de code ? Cliquez ici pour renvoyer", "passwordNotMatch": "Les mots de passe ne correspondent pas", "signupError": "Une erreur s'est produite lors de l'inscription", "pangolinLogoAlt": "Logo Pangolin", "inviteAlready": "On dirait que vous avez été invité !", "inviteAlreadyDescription": "Pour accepter l'invitation, vous devez vous connecter ou créer un compte.", "signupQuestion": "Vous avez déjà un compte ?", "login": "Se connecter", "resourceNotFound": "Ressource introuvable", "resourceNotFoundDescription": "La ressource que vous essayez d'accéder n'existe pas.", "pincodeRequirementsLength": "Le code PIN doit comporter exactement 6 chiffres", "pincodeRequirementsChars": "Le code PIN ne doit contenir que des chiffres", "passwordRequirementsLength": "Le mot de passe doit comporter au moins 1 caractère", "passwordRequirementsTitle": "Exigences relatives au mot de passe :", "passwordRequirementLength": "Au moins 8 caractères", "passwordRequirementUppercase": "Au moins une lettre majuscule", "passwordRequirementLowercase": "Au moins une lettre minuscule", "passwordRequirementNumber": "Au moins un chiffre", "passwordRequirementSpecial": "Au moins un caractère spécial", "passwordRequirementsMet": "✓ Le mot de passe répond à toutes les exigences", "passwordStrength": "Solidité du mot de passe", "passwordStrengthWeak": "Faible", "passwordStrengthMedium": "Moyen", "passwordStrengthStrong": "Fort", "passwordRequirements": "Exigences :", "passwordRequirementLengthText": "8+ caractères", "passwordRequirementUppercaseText": "Lettre majuscule (A-Z)", "passwordRequirementLowercaseText": "Lettre minuscule (a-z)", "passwordRequirementNumberText": "Nombre (0-9)", "passwordRequirementSpecialText": "Caractère spécial (!@#$%...)", "passwordsDoNotMatch": "Les mots de passe ne correspondent pas", "otpEmailRequirementsLength": "L'OTP doit comporter au moins 1 caractère", "otpEmailSent": "OTP envoyé", "otpEmailSentDescription": "Un OTP a été envoyé à votre e-mail", "otpEmailErrorAuthenticate": "Échec de l'authentification par e-mail", "pincodeErrorAuthenticate": "Échec de l'authentification avec le code PIN", "passwordErrorAuthenticate": "Échec de l'authentification avec le mot de passe", "poweredBy": "Propulsé par", "authenticationRequired": "Authentification requise", "authenticationMethodChoose": "Choisissez votre méthode préférée pour accéder à {name}", "authenticationRequest": "Vous devez vous authentifier pour accéder à {name}", "user": "Utilisateur", "pincodeInput": "Code PIN à 6 chiffres", "pincodeSubmit": "Se connecter avec le PIN", "passwordSubmit": "Se connecter avec le mot de passe", "otpEmailDescription": "Un code à usage unique sera envoyé à cet e-mail.", "otpEmailSend": "Envoyer le code à usage unique", "otpEmail": "Mot de passe à usage unique (OTP)", "otpEmailSubmit": "Soumettre l'OTP", "backToEmail": "Retour à l'e-mail", "noSupportKey": "Le serveur fonctionne sans clé de supporteur. Pensez à soutenir le projet !", "accessDenied": "Accès refusé", "accessDeniedDescription": "Vous n'êtes pas autorisé à accéder à cette ressource. Si c'est une erreur, veuillez contacter l'administrateur.", "accessTokenError": "Erreur lors de la vérification du jeton d'accès", "accessGranted": "Accès accordé", "accessUrlInvalid": "URL d'accès invalide", "accessGrantedDescription": "L'accès à cette ressource vous a été accordé. Redirection...", "accessUrlInvalidDescription": "Cette URL d'accès partagé n'est pas valide. Veuillez contacter le propriétaire de la ressource pour obtenir une nouvelle URL.", "tokenInvalid": "Jeton invalide", "pincodeInvalid": "Code invalide", "passwordErrorRequestReset": "Échec de la demande de réinitialisation :", "passwordErrorReset": "Échec de la réinitialisation du mot de passe :", "passwordResetSuccess": "Mot de passe réinitialisé avec succès ! Retour à la connexion...", "passwordReset": "Réinitialiser le mot de passe", "passwordResetDescription": "Suivez les étapes pour réinitialiser votre mot de passe", "passwordResetSent": "Nous allons envoyer un code de réinitialisation à cette adresse e-mail.", "passwordResetCode": "Code de réinitialisation", "passwordResetCodeDescription": "Vérifiez votre e-mail pour le code de réinitialisation.", "generatePasswordResetCode": "Générer le code de réinitialisation du mot de passe", "passwordResetCodeGenerated": "Code de réinitialisation du mot de passe généré", "passwordResetCodeGeneratedDescription": "Partagez ce code avec l'utilisateur. Il peut l'utiliser pour réinitialiser son mot de passe.", "passwordResetUrl": "Reset URL", "passwordNew": "Nouveau mot de passe", "passwordNewConfirm": "Confirmer le nouveau mot de passe", "changePassword": "Changer le mot de passe", "changePasswordDescription": "Mettre à jour le mot de passe de votre compte", "oldPassword": "Mot de passe actuel", "newPassword": "Nouveau mot de passe", "confirmNewPassword": "Confirmer le nouveau mot de passe", "changePasswordError": "Impossible de changer le mot de passe", "changePasswordErrorDescription": "Une erreur s'est produite lors de la modification de votre mot de passe", "changePasswordSuccess": "Mot de passe modifié avec succès", "changePasswordSuccessDescription": "Votre mot de passe a été mis à jour avec succès", "passwordExpiryRequired": "Expiration du mot de passe requise", "passwordExpiryDescription": "Cette organisation vous demande de changer votre mot de passe tous les {maxDays} jours.", "changePasswordNow": "Changer le mot de passe maintenant", "pincodeAuth": "Code d'authentification", "pincodeSubmit2": "Soumettre le code", "passwordResetSubmit": "Demander la réinitialisation", "passwordResetAlreadyHaveCode": "Entrer le code", "passwordResetSmtpRequired": "Veuillez contacter votre administrateur", "passwordResetSmtpRequiredDescription": "Un code de réinitialisation du mot de passe est requis pour réinitialiser votre mot de passe. Veuillez contacter votre administrateur pour obtenir de l'aide.", "passwordBack": "Retour au mot de passe", "loginBack": "Revenir à la page de connexion principale", "signup": "S'inscrire", "loginStart": "Connectez-vous pour commencer", "idpOidcTokenValidating": "Validation du jeton OIDC", "idpOidcTokenResponse": "Valider la réponse du jeton OIDC", "idpErrorOidcTokenValidating": "Erreur lors de la validation du jeton OIDC", "idpConnectingTo": "Connexion à {name}", "idpConnectingToDescription": "Validation de votre identité", "idpConnectingToProcess": "Connexion...", "idpConnectingToFinished": "Connecté", "idpErrorConnectingTo": "Un problème est survenu lors de la connexion à {name}. Veuillez contacter votre administrateur.", "idpErrorNotFound": "IdP introuvable", "inviteInvalid": "Invitation invalide", "inviteInvalidDescription": "Le lien d'invitation n'est pas valide.", "inviteErrorWrongUser": "L'invitation n'est pas pour cet utilisateur", "inviteErrorUserNotExists": "L'utilisateur n'existe pas. Veuillez d'abord créer un compte.", "inviteErrorLoginRequired": "Vous devez être connecté pour accepter une invitation", "inviteErrorExpired": "L'invitation a peut-être expiré", "inviteErrorRevoked": "L'invitation a peut-être été révoquée", "inviteErrorTypo": "Il pourrait y avoir une erreur de frappe dans le lien d'invitation", "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.", "overview": "Vue d'ensemble", "home": "Accueil", "settings": "Paramètres", "usersAll": "Tous les utilisateurs", "license": "Licence", "pangolinDashboard": "Tableau de bord - Pangolin", "noResults": "Aucun résultat trouvé.", "terabytes": "{count} To", "gigabytes": "{count} Go", "megabytes": "{count} Mo", "tagsEntered": "Tags saisis", "tagsEnteredDescription": "Ce sont les tags que vous avez saisis.", "tagsWarnCannotBeLessThanZero": "maxTags et minTags ne peuvent pas être inférieurs à 0", "tagsWarnNotAllowedAutocompleteOptions": "Tag non autorisé selon les options d'autocomplétion", "tagsWarnInvalid": "Tag invalide selon validateTag", "tagWarnTooShort": "Le tag {tagText} est trop court", "tagWarnTooLong": "Le tag {tagText} est trop long", "tagsWarnReachedMaxNumber": "Nombre maximum de tags autorisés atteint", "tagWarnDuplicate": "Tag en double {tagText} non ajouté", "supportKeyInvalid": "Clé invalide", "supportKeyInvalidDescription": "Votre clé de support est invalide.", "supportKeyValid": "Clé valide", "supportKeyValidDescription": "Votre clé de support a été validée. Merci pour votre soutien !", "supportKeyErrorValidationDescription": "Échec de la validation de la clé de support.", "supportKey": "Soutenez le développement et adoptez un Pangolin !", "supportKeyDescription": "Achetez une clé de support pour nous aider à continuer le développement de Pangolin pour la communauté. Votre contribution nous permet de consacrer plus de temps à maintenir et ajouter de nouvelles fonctionnalités à l'application pour tous. Nous n'utiliserons jamais cela pour verrouiller des fonctionnalités. Ceci est distinct de toute Édition Commerciale.", "supportKeyPet": "Vous pourrez aussi adopter et rencontrer votre propre Pangolin de compagnie !", "supportKeyPurchase": "Les paiements sont traités via GitHub. Ensuite, vous pourrez récupérer votre clé sur", "supportKeyPurchaseLink": "notre site web", "supportKeyPurchase2": "et l'utiliser ici.", "supportKeyLearnMore": "En savoir plus.", "supportKeyOptions": "Veuillez sélectionner l'option qui vous convient le mieux.", "supportKetOptionFull": "Support complet", "forWholeServer": "Pour tout le serveur", "lifetimePurchase": "Achat à vie", "supporterStatus": "Statut de supporter", "buy": "Acheter", "supportKeyOptionLimited": "Support limité", "forFiveUsers": "Pour 5 utilisateurs ou moins", "supportKeyRedeem": "Utiliser une clé de support", "supportKeyHideSevenDays": "Masquer pendant 7 jours", "supportKeyEnter": "Saisir la clé de support", "supportKeyEnterDescription": "Rencontrez votre propre Pangolin de compagnie !", "githubUsername": "Nom d'utilisateur GitHub", "supportKeyInput": "Clé de support", "supportKeyBuy": "Acheter une clé de support", "logoutError": "Erreur lors de la déconnexion", "signingAs": "Connecté en tant que", "serverAdmin": "Admin Serveur", "managedSelfhosted": "Gestion autonome", "otpEnable": "Activer l'authentification à deux facteurs", "otpDisable": "Désactiver l'authentification à deux facteurs", "logout": "Déconnexion", "licenseTierProfessionalRequired": "Édition Professionnelle Requise", "licenseTierProfessionalRequiredDescription": "Cette fonctionnalité n'est disponible que dans l'Édition Professionnelle.", "actionGetOrg": "Obtenir l'organisation", "updateOrgUser": "Mise à jour de l'utilisateur Org", "createOrgUser": "Créer un utilisateur Org", "actionUpdateOrg": "Mettre à jour l'organisation", "actionRemoveInvitation": "Supprimer l'invitation", "actionUpdateUser": "Mettre à jour l'utilisateur", "actionGetUser": "Obtenir l'utilisateur", "actionGetOrgUser": "Obtenir l'utilisateur de l'organisation", "actionListOrgDomains": "Lister les domaines de l'organisation", "actionGetDomain": "Obtenir un domaine", "actionCreateOrgDomain": "Créer un domaine", "actionUpdateOrgDomain": "Mettre à jour le domaine", "actionDeleteOrgDomain": "Supprimer le domaine", "actionGetDNSRecords": "Récupérer les enregistrements DNS", "actionRestartOrgDomain": "Redémarrer le domaine", "actionCreateSite": "Créer un site", "actionDeleteSite": "Supprimer un site", "actionGetSite": "Obtenir un site", "actionListSites": "Lister les sites", "actionApplyBlueprint": "Appliquer la Config", "actionListBlueprints": "Lister les plans", "actionGetBlueprint": "Obtenez un plan", "setupToken": "Jeton de configuration", "setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.", "setupTokenRequired": "Le jeton de configuration est requis.", "actionUpdateSite": "Mettre à jour un site", "actionListSiteRoles": "Lister les rôles autorisés du site", "actionCreateResource": "Créer une ressource", "actionDeleteResource": "Supprimer une ressource", "actionGetResource": "Obtenir une ressource", "actionListResource": "Lister les ressources", "actionUpdateResource": "Mettre à jour une ressource", "actionListResourceUsers": "Lister les utilisateurs de la ressource", "actionSetResourceUsers": "Définir les utilisateurs de la ressource", "actionSetAllowedResourceRoles": "Définir les rôles autorisés de la ressource", "actionListAllowedResourceRoles": "Lister les rôles autorisés de la ressource", "actionSetResourcePassword": "Définir le mot de passe de la ressource", "actionSetResourcePincode": "Définir le code PIN de la ressource", "actionSetResourceEmailWhitelist": "Définir la liste blanche des emails de la ressource", "actionGetResourceEmailWhitelist": "Obtenir la liste blanche des emails de la ressource", "actionCreateTarget": "Créer une cible", "actionDeleteTarget": "Supprimer une cible", "actionGetTarget": "Obtenir une cible", "actionListTargets": "Lister les cibles", "actionUpdateTarget": "Mettre à jour une cible", "actionCreateRole": "Créer un rôle", "actionDeleteRole": "Supprimer un rôle", "actionGetRole": "Obtenir un rôle", "actionListRole": "Lister les rôles", "actionUpdateRole": "Mettre à jour un rôle", "actionListAllowedRoleResources": "Lister les ressources autorisées du rôle", "actionInviteUser": "Inviter un utilisateur", "actionRemoveUser": "Supprimer un utilisateur", "actionListUsers": "Lister les utilisateurs", "actionAddUserRole": "Ajouter un rôle utilisateur", "actionGenerateAccessToken": "Générer un jeton d'accès", "actionDeleteAccessToken": "Supprimer un jeton d'accès", "actionListAccessTokens": "Lister les jetons d'accès", "actionCreateResourceRule": "Créer une règle de ressource", "actionDeleteResourceRule": "Supprimer une règle de ressource", "actionListResourceRules": "Lister les règles de ressource", "actionUpdateResourceRule": "Mettre à jour une règle de ressource", "actionListOrgs": "Lister les organisations", "actionCheckOrgId": "Vérifier l'ID", "actionCreateOrg": "Créer une organisation", "actionDeleteOrg": "Supprimer une organisation", "actionListApiKeys": "Lister les clés API", "actionListApiKeyActions": "Lister les actions des clés API", "actionSetApiKeyActions": "Définir les actions autorisées des clés API", "actionCreateApiKey": "Créer une clé API", "actionDeleteApiKey": "Supprimer une clé API", "actionCreateIdp": "Créer un IDP", "actionUpdateIdp": "Mettre à jour un IDP", "actionDeleteIdp": "Supprimer un IDP", "actionListIdps": "Lister les IDP", "actionGetIdp": "Obtenir un IDP", "actionCreateIdpOrg": "Créer une politique d'organisation IDP", "actionDeleteIdpOrg": "Supprimer une politique d'organisation IDP", "actionListIdpOrgs": "Lister les organisations IDP", "actionUpdateIdpOrg": "Mettre à jour une organisation IDP", "actionCreateClient": "Créer un client", "actionDeleteClient": "Supprimer le client", "actionArchiveClient": "Archiver le client", "actionUnarchiveClient": "Désarchiver le client", "actionBlockClient": "Bloquer le client", "actionUnblockClient": "Débloquer le client", "actionUpdateClient": "Mettre à jour le client", "actionListClients": "Liste des clients", "actionGetClient": "Obtenir le client", "actionCreateSiteResource": "Créer une ressource de site", "actionDeleteSiteResource": "Supprimer une ressource de site", "actionGetSiteResource": "Obtenir une ressource de site", "actionListSiteResources": "Lister les ressources de site", "actionUpdateSiteResource": "Mettre à jour une ressource de site", "actionListInvitations": "Lister les invitations", "actionExportLogs": "Exporter les journaux", "actionViewLogs": "Voir les logs", "noneSelected": "Aucune sélection", "orgNotFound2": "Aucune organisation trouvée.", "searchPlaceholder": "Recherche...", "emptySearchOptions": "Aucune option trouvée", "create": "Créer", "orgs": "Organisations", "loginError": "Une erreur inattendue s'est produite. Veuillez réessayer.", "loginRequiredForDevice": "La connexion est requise pour votre appareil.", "passwordForgot": "Mot de passe oublié ?", "otpAuth": "Authentification à deux facteurs", "otpAuthDescription": "Entrez le code de votre application d'authentification ou l'un de vos codes de secours à usage unique.", "otpAuthSubmit": "Soumettre le code", "idpContinue": "Ou continuer avec", "otpAuthBack": "Retour au mot de passe", "navbar": "Menu de navigation", "navbarDescription": "Menu de navigation principal de l'application", "navbarDocsLink": "Documentation", "otpErrorEnable": "Impossible d'activer l'A2F", "otpErrorEnableDescription": "Une erreur s'est produite lors de l'activation de l'A2F", "otpSetupCheckCode": "Veuillez entrer un code à 6 chiffres", "otpSetupCheckCodeRetry": "Code invalide. Veuillez réessayer.", "otpSetup": "Activer l'authentification à deux facteurs", "otpSetupDescription": "Sécurisez votre compte avec une couche de protection supplémentaire", "otpSetupScanQr": "Scannez ce code QR avec votre application d'authentification ou entrez la clé secrète manuellement :", "otpSetupSecretCode": "Code d'authentification", "otpSetupSuccess": "Authentification à deux facteurs activée", "otpSetupSuccessStoreBackupCodes": "Votre compte est maintenant plus sécurisé. N'oubliez pas de sauvegarder vos codes de secours.", "otpErrorDisable": "Impossible de désactiver l'A2F", "otpErrorDisableDescription": "Une erreur s'est produite lors de la désactivation de l'A2F", "otpRemove": "Désactiver l'authentification à deux facteurs", "otpRemoveDescription": "Désactiver l'authentification à deux facteurs pour votre compte", "otpRemoveSuccess": "Authentification à deux facteurs désactivée", "otpRemoveSuccessMessage": "L'authentification à deux facteurs a été désactivée pour votre compte. Vous pouvez la réactiver à tout moment.", "otpRemoveSubmit": "Désactiver l'A2F", "paginator": "Page {current} sur {last}", "paginatorToFirst": "Aller à la première page", "paginatorToPrevious": "Aller à la page précédente", "paginatorToNext": "Aller à la page suivante", "paginatorToLast": "Aller à la dernière page", "copyText": "Copier le texte", "copyTextFailed": "Échec de la copie du texte : ", "copyTextClipboard": "Copier dans le presse-papiers", "inviteErrorInvalidConfirmation": "Confirmation invalide", "passwordRequired": "Le mot de passe est requis", "allowAll": "Tout autoriser", "permissionsAllowAll": "Autoriser toutes les autorisations", "githubUsernameRequired": "Le nom d'utilisateur GitHub est requis", "supportKeyRequired": "La clé de supporter est requise", "passwordRequirementsChars": "Le mot de passe doit comporter au moins 8 caractères", "language": "Langue", "verificationCodeRequired": "Le code est requis", "userErrorNoUpdate": "Pas d'utilisateur à mettre à jour", "siteErrorNoUpdate": "Pas de site à mettre à jour", "resourceErrorNoUpdate": "Pas de ressource à mettre à jour", "authErrorNoUpdate": "Pas d'informations d'authentification à mettre à jour", "orgErrorNoUpdate": "Pas d'organisation à mettre à jour", "orgErrorNoProvided": "Aucune organisation fournie", "apiKeysErrorNoUpdate": "Pas de clé API à mettre à jour", "sidebarOverview": "Aperçu", "sidebarHome": "Domicile", "sidebarSites": "Nœuds", "sidebarApprovals": "Demandes d'approbation", "sidebarResources": "Ressource", "sidebarProxyResources": "Publique", "sidebarClientResources": "Privé", "sidebarAccessControl": "Contrôle d'accès", "sidebarLogsAndAnalytics": "Journaux & Analytiques", "sidebarTeam": "Equipe", "sidebarUsers": "Utilisateurs", "sidebarAdmin": "Administrateur", "sidebarInvitations": "Invitations", "sidebarRoles": "Rôles", "sidebarShareableLinks": "Liens", "sidebarApiKeys": "Clés API", "sidebarSettings": "Réglages", "sidebarAllUsers": "Tous les utilisateurs", "sidebarIdentityProviders": "Fournisseurs d'identité", "sidebarLicense": "Licence", "sidebarClients": "Clients", "sidebarUserDevices": "Périphériques utilisateur", "sidebarMachineClients": "Machines", "sidebarDomains": "Domaines", "sidebarGeneral": "Gérer", "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", "blueprintAdd": "Ajouter une Config", "blueprintGoBack": "Voir toutes les Configs", "blueprintCreate": "Créer une Config", "blueprintCreateDescription2": "Suivez les étapes ci-dessous pour créer et appliquer une nouvelle config", "blueprintDetails": "Détails de la Config", "blueprintDetailsDescription": "Voir le résultat du plan appliqué et les erreurs qui se sont produites", "blueprintInfo": "Informations sur la Config", "message": "Message", "blueprintContentsDescription": "Définir le contenu YAML décrivant l'infrastructure", "blueprintErrorCreateDescription": "Une erreur s'est produite lors de l'application de la config", "blueprintErrorCreate": "Erreur lors de la création de la config", "searchBlueprintProgress": "Rechercher des configs...", "appliedAt": "Appliqué à", "source": "Source", "contents": "Contenus", "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.", "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.", "containerName": "Nom", "containerImage": "Image", "containerState": "État", "containerNetworks": "Réseaux", "containerHostnameIp": "Nom d'hôte/IP", "containerLabels": "Étiquettes", "containerLabelsCount": "{count, plural, one {# étiquette} other {# étiquettes}}", "containerLabelsTitle": "Étiquettes de conteneur", "containerLabelEmpty": "", "containerPorts": "Ports", "containerPortsMore": "+{count} de plus", "containerActions": "Actions", "select": "Sélectionner", "noContainersMatchingFilters": "Aucun conteneur ne correspond aux filtres actuels.", "showContainersWithoutPorts": "Afficher les conteneurs sans ports", "showStoppedContainers": "Afficher les conteneurs arrêtés", "noContainersFound": "Aucun conteneur trouvé. Assurez-vous que les conteneurs Docker sont en cours d'exécution.", "searchContainersPlaceholder": "Rechercher dans les conteneurs {count}...", "searchResultsCount": "{count, plural, one {# résultat} other {# résultats}}", "filters": "Filtres", "filterOptions": "Options de filtre", "filterPorts": "Ports", "filterStopped": "Arrêté", "clearAllFilters": "Effacer tous les filtres", "columns": "Colonnes", "toggleColumns": "Activer/désactiver les colonnes", "refreshContainersList": "Rafraîchir la liste des conteneurs", "searching": "Recherche en cours...", "noContainersFoundMatching": "Aucun conteneur correspondant à \"{filter}\".", "light": "clair", "dark": "sombre", "system": "système", "theme": "Thème", "subnetRequired": "Le sous-réseau est requis", "initialSetupTitle": "Configuration initiale du serveur", "initialSetupDescription": "Créer le compte administrateur du serveur initial. Un seul administrateur serveur peut exister. Vous pouvez toujours changer ces informations d'identification plus tard.", "createAdminAccount": "Créer un compte administrateur", "setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.", "certificateStatus": "Statut du certificat", "loading": "Chargement", "loadingAnalytics": "Chargement de l'analyse", "restart": "Redémarrer", "domains": "Domaines", "domainsDescription": "Créer et gérer les domaines disponibles dans l'organisation", "domainsSearch": "Rechercher des domaines...", "domainAdd": "Ajouter un domaine", "domainAddDescription": "Inscrire un nouveau domaine à l'organisation", "domainCreate": "Créer un domaine", "domainCreatedDescription": "Domaine créé avec succès", "domainDeletedDescription": "Domaine supprimé avec succès", "domainQuestionRemove": "Êtes-vous sûr de vouloir supprimer le domaine ?", "domainMessageRemove": "Une fois supprimé, le domaine ne sera plus associé à l'organisation.", "domainConfirmDelete": "Confirmer la suppression du domaine", "domainDelete": "Supprimer le domaine", "domain": "Domaine", "selectDomainTypeNsName": "Délégation de domaine (NS)", "selectDomainTypeNsDescription": "Ce domaine et tous ses sous-domaines. Utilisez cela lorsque vous souhaitez contrôler une zone de domaine entière.", "selectDomainTypeCnameName": "Domaine unique (CNAME)", "selectDomainTypeCnameDescription": "Juste ce domaine spécifique. Utilisez ce paramètre pour des sous-domaines individuels ou des entrées de domaine spécifiques.", "selectDomainTypeWildcardName": "Domaine Générique", "selectDomainTypeWildcardDescription": "Ce domaine et ses sous-domaines.", "domainDelegation": "Domaine Unique", "selectType": "Sélectionnez un type", "actions": "Actions", "refresh": "Actualiser", "refreshError": "Échec de l'actualisation des données", "verified": "Vérifié", "pending": "En attente", "pendingApproval": "En attente d'approbation", "sidebarBilling": "Facturation", "billing": "Facturation", "orgBillingDescription": "Gérer les informations de facturation et les abonnements", "github": "GitHub", "pangolinHosted": "Pangolin Hébergement", "fossorial": "Fossorial", "completeAccountSetup": "Complétez la configuration du compte", "completeAccountSetupDescription": "Définissez votre mot de passe pour commencer", "accountSetupSent": "Nous enverrons un code de configuration de compte à cette adresse e-mail.", "accountSetupCode": "Code de configuration", "accountSetupCodeDescription": "Vérifiez votre e-mail pour le code de configuration.", "passwordCreate": "Créer un mot de passe", "passwordCreateConfirm": "Confirmer le mot de passe", "accountSetupSubmit": "Envoyer le code de configuration", "completeSetup": "Configuration complète", "accountSetupSuccess": "Configuration du compte terminée! Bienvenue chez Pangolin !", "documentation": "Documentation", "saveAllSettings": "Enregistrer tous les paramètres", "saveResourceTargets": "Enregistrer les cibles", "saveResourceHttp": "Enregistrer les paramètres de proxy", "saveProxyProtocol": "Enregistrer les paramètres du protocole proxy", "settingsUpdated": "Paramètres mis à jour", "settingsUpdatedDescription": "Paramètres mis à jour avec succès", "settingsErrorUpdate": "Échec de la mise à jour des paramètres", "settingsErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour des paramètres", "sidebarCollapse": "Réduire", "sidebarExpand": "Développer", "productUpdateMoreInfo": "{noOfUpdates} mises à jour de plus", "productUpdateInfo": "{noOfUpdates} mises à jour", "productUpdateWhatsNew": "Quoi de neuf", "productUpdateTitle": "Mises à jour", "productUpdateEmpty": "Aucune mise à jour", "dismissAll": "Tout cacher", "pangolinUpdateAvailable": "Mise à jour disponible", "pangolinUpdateAvailableInfo": "La version {version} est prête à être installée", "pangolinUpdateAvailableReleaseNotes": "Voir les notes de publication", "newtUpdateAvailable": "Mise à jour disponible", "newtUpdateAvailableInfo": "Une nouvelle version de Newt est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", "domainPickerEnterDomain": "Domaine", "domainPickerPlaceholder": "monapp.exemple.com", "domainPickerDescription": "Entrez le domaine complet de la ressource pour voir les options disponibles.", "domainPickerDescriptionSaas": "Entrez un domaine complet, un sous-domaine ou juste un nom pour voir les options disponibles", "domainPickerTabAll": "Tous", "domainPickerTabOrganization": "Organisation", "domainPickerTabProvided": "Fournis", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Vérification de la disponibilité...", "domainPickerNoMatchingDomains": "Aucun domaine correspondant trouvé. Essayez un autre domaine ou vérifiez les paramètres de domaine de l'organisation.", "domainPickerOrganizationDomains": "Domaines de l'organisation", "domainPickerProvidedDomains": "Domaines fournis", "domainPickerSubdomain": "Sous-domaine : {subdomain}", "domainPickerNamespace": "Espace de noms : {namespace}", "domainPickerShowMore": "Afficher plus", "regionSelectorTitle": "Sélectionner Région", "regionSelectorInfo": "Sélectionner une région nous aide à offrir de meilleures performances pour votre localisation. Vous n'avez pas besoin d'être dans la même région que votre serveur.", "regionSelectorPlaceholder": "Choisissez une région", "regionSelectorComingSoon": "Bientôt disponible", "billingLoadingSubscription": "Chargement de l'abonnement...", "billingFreeTier": "Niveau gratuit", "billingWarningOverLimit": "Attention : Vous avez dépassé une ou plusieurs limites d'utilisation. Vos sites ne se connecteront pas tant que vous n'avez pas modifié votre abonnement ou ajusté votre utilisation.", "billingUsageLimitsOverview": "Vue d'ensemble des limites d'utilisation", "billingMonitorUsage": "Surveillez votre consommation par rapport aux limites configurées. Si vous avez besoin d'une augmentation des limites, veuillez nous contacter à support@pangolin.net.", "billingDataUsage": "Utilisation des données", "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", "billingIncludedUsage": "Utilisation incluse", "billingIncludedUsageDescription": "Utilisation incluse dans votre plan d'abonnement actuel", "billingFreeTierIncludedUsage": "Tolérances d'utilisation du niveau gratuit", "billingIncluded": "inclus", "billingEstimatedTotal": "Total estimé :", "billingNotes": "Notes", "billingEstimateNote": "Ceci est une estimation basée sur votre utilisation actuelle.", "billingActualChargesMayVary": "Les frais réels peuvent varier.", "billingBilledAtEnd": "Vous serez facturé à la fin de la période de facturation.", "billingModifySubscription": "Modifier l'abonnement", "billingStartSubscription": "Démarrer l'abonnement", "billingRecurringCharge": "Frais récurrents", "billingManageSubscriptionSettings": "Gérer les paramètres et préférences d'abonnement", "billingNoActiveSubscription": "Vous n'avez pas d'abonnement actif. Commencez votre abonnement pour augmenter les limites d'utilisation.", "billingFailedToLoadSubscription": "Échec du chargement de l'abonnement", "billingFailedToLoadUsage": "Échec du chargement de l'utilisation", "billingFailedToGetCheckoutUrl": "Échec pour obtenir l'URL de paiement", "billingPleaseTryAgainLater": "Veuillez réessayer plus tard.", "billingCheckoutError": "Erreur de paiement", "billingFailedToGetPortalUrl": "Échec pour obtenir l'URL du portail", "billingPortalError": "Erreur du portail", "billingDataUsageInfo": "Vous êtes facturé pour toutes les données transférées via vos tunnels sécurisés lorsque vous êtes connecté au cloud. Cela inclut le trafic entrant et sortant sur tous vos sites. Lorsque vous atteignez votre limite, vos sites se déconnecteront jusqu'à ce que vous mettiez à niveau votre plan ou réduisiez l'utilisation. Les données ne sont pas facturées lors de l'utilisation de nœuds.", "billingSInfo": "Combien de sites vous pouvez utiliser", "billingUsersInfo": "Combien d'utilisateurs vous pouvez utiliser", "billingDomainInfo": "Combien de domaines vous pouvez utiliser", "billingRemoteExitNodesInfo": "Combien de nœuds distants vous pouvez utiliser", "billingLicenseKeys": "Clés de licence", "billingLicenseKeysDescription": "Gérer vos abonnements à la clé de licence", "billingLicenseSubscription": "Abonnement à la licence", "billingInactive": "Inactif", "billingLicenseItem": "Article de la licence", "billingQuantity": "Quantité", "billingTotal": "total", "billingModifyLicenses": "Modifier l'abonnement à la licence", "domainNotFound": "Domaine introuvable", "domainNotFoundDescription": "Cette ressource est désactivée car le domaine n'existe plus dans notre système. Veuillez définir un nouveau domaine pour cette ressource.", "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", "securityKeyRegister": "Enregistrer une nouvelle clé de sécurité", "securityKeyList": "Vos clés de sécurité", "securityKeyNone": "Aucune clé de sécurité enregistrée", "securityKeyNameRequired": "Le nom est requis", "securityKeyRemove": "Supprimer", "securityKeyLastUsed": "Dernière utilisation : {date}", "securityKeyNameLabel": "Nom", "securityKeyRegisterSuccess": "Clé de sécurité enregistrée avec succès", "securityKeyRegisterError": "Échec de l'enregistrement de la clé de sécurité", "securityKeyRemoveSuccess": "Clé de sécurité supprimée avec succès", "securityKeyRemoveError": "Échec de la suppression de la clé de sécurité", "securityKeyLoadError": "Échec du chargement des clés de sécurité", "securityKeyLogin": "Utiliser la clé de sécurité", "securityKeyAuthError": "Échec de l'authentification avec la clé de sécurité", "securityKeyRecommendation": "Envisagez d'enregistrer une autre clé de sécurité sur un appareil différent pour vous assurer de ne pas être bloqué de votre compte.", "registering": "Enregistrement...", "securityKeyPrompt": "Veuillez vérifier votre identité à l'aide de votre clé de sécurité. Assurez-vous que votre clé de sécurité est connectée et prête.", "securityKeyBrowserNotSupported": "Votre navigateur ne prend pas en charge les clés de sécurité. Veuillez utiliser un navigateur moderne comme Chrome, Firefox ou Safari.", "securityKeyPermissionDenied": "Veuillez autoriser l'accès à votre clé de sécurité pour continuer la connexion.", "securityKeyRemovedTooQuickly": "Veuillez garder votre clé de sécurité connectée jusqu'à ce que le processus de connexion soit terminé.", "securityKeyNotSupported": "Votre clé de sécurité peut ne pas être compatible. Veuillez essayer une clé de sécurité différente.", "securityKeyUnknownError": "Un problème est survenu avec votre clé de sécurité. Veuillez réessayer.", "twoFactorRequired": "L'authentification à deux facteurs est requise pour enregistrer une clé de sécurité.", "twoFactor": "Authentification à deux facteurs", "twoFactorAuthentication": "Authentification à deux facteurs", "twoFactorDescription": "Cette organisation nécessite une authentification à deux facteurs.", "enableTwoFactor": "Activer l'authentification à deux facteurs", "organizationSecurityPolicy": "Politique de sécurité de l'organisation", "organizationSecurityPolicyDescription": "Cette organisation a des exigences de sécurité qui doivent être remplies avant que vous puissiez y accéder", "securityRequirements": "Exigences de sécurité", "allRequirementsMet": "Toutes les conditions ont été remplies", "completeRequirementsToContinue": "Remplissez les conditions ci-dessous pour continuer à accéder à cette organisation", "youCanNowAccessOrganization": "Vous pouvez maintenant accéder à cette organisation", "reauthenticationRequired": "Durée de la session", "reauthenticationDescription": "Cette organisation vous demande de vous connecter tous les {maxDays} jours.", "reauthenticationDescriptionHours": "Cette organisation vous demande de vous connecter toutes les {maxHours} heures.", "reauthenticateNow": "Reconnectez-vous", "adminEnabled2FaOnYourAccount": "Votre administrateur a activé l'authentification à deux facteurs pour {email}. Veuillez terminer le processus d'installation pour continuer.", "securityKeyAdd": "Ajouter une clé de sécurité", "securityKeyRegisterTitle": "Enregistrer une nouvelle clé de sécurité", "securityKeyRegisterDescription": "Connectez votre clé de sécurité et saisissez un nom pour l'identifier", "securityKeyTwoFactorRequired": "Authentification à deux facteurs requise", "securityKeyTwoFactorDescription": "Veuillez entrer votre code d'authentification à deux facteurs pour enregistrer la clé de sécurité", "securityKeyTwoFactorRemoveDescription": "Veuillez entrer votre code d'authentification à deux facteurs pour supprimer la clé de sécurité", "securityKeyTwoFactorCode": "Code à deux facteurs", "securityKeyRemoveTitle": "Supprimer la clé de sécurité", "securityKeyRemoveDescription": "Saisissez votre mot de passe pour supprimer la clé de sécurité \"{name}\"", "securityKeyNoKeysRegistered": "Aucune clé de sécurité enregistrée", "securityKeyNoKeysDescription": "Ajoutez une clé de sécurité pour améliorer la sécurité de votre compte", "createDomainRequired": "Le domaine est requis", "createDomainAddDnsRecords": "Ajouter des enregistrements DNS", "createDomainAddDnsRecordsDescription": "Ajouter les enregistrements DNS suivants à votre fournisseur de domaine pour compléter la configuration.", "createDomainNsRecords": "Enregistrements NS", "createDomainRecord": "Enregistrement", "createDomainType": "Type :", "createDomainName": "Nom :", "createDomainValue": "Valeur :", "createDomainCnameRecords": "Enregistrements CNAME", "createDomainARecords": "Enregistrements A", "createDomainRecordNumber": "Enregistrement {number}", "createDomainTxtRecords": "Enregistrements TXT", "createDomainSaveTheseRecords": "Enregistrez ces enregistrements", "createDomainSaveTheseRecordsDescription": "Assurez-vous de sauvegarder ces enregistrements DNS car vous ne les reverrez pas.", "createDomainDnsPropagation": "Propagation DNS", "createDomainDnsPropagationDescription": "Les modifications DNS peuvent mettre du temps à se propager sur internet. Cela peut prendre de quelques minutes à 48 heures selon votre fournisseur DNS et les réglages TTL.", "resourcePortRequired": "Le numéro de port est requis pour les ressources non-HTTP", "resourcePortNotAllowed": "Le numéro de port ne doit pas être défini pour les ressources HTTP", "billingPricingCalculatorLink": "Calculateur de prix", "billingYourPlan": "Votre plan", "billingViewOrModifyPlan": "Voir ou modifier votre forfait actuel", "billingViewPlanDetails": "Voir les détails du plan", "billingUsageAndLimits": "Utilisation et limites", "billingViewUsageAndLimits": "Voir les limites de votre plan et l'utilisation actuelle", "billingCurrentUsage": "Utilisation actuelle", "billingMaximumLimits": "Limites maximum", "billingRemoteNodes": "Nœuds distants", "billingUnlimited": "Illimité", "billingPaidLicenseKeys": "Clés de licence payantes", "billingManageLicenseSubscription": "Gérer votre abonnement pour les clés de licence auto-hébergées payantes", "billingCurrentKeys": "Clés actuelles", "billingModifyCurrentPlan": "Modifier le plan actuel", "billingConfirmUpgrade": "Confirmer la mise à niveau", "billingConfirmDowngrade": "Confirmer la rétrogradation", "billingConfirmUpgradeDescription": "Vous êtes sur le point de mettre à niveau votre offre. Examinez les nouvelles limites et les nouveaux prix ci-dessous.", "billingConfirmDowngradeDescription": "Vous êtes sur le point de rétrograder votre forfait. Examinez les nouvelles limites et les prix ci-dessous.", "billingPlanIncludes": "Le forfait comprend", "billingProcessing": "Traitement en cours...", "billingConfirmUpgradeButton": "Confirmer la mise à niveau", "billingConfirmDowngradeButton": "Confirmer la rétrogradation", "billingLimitViolationWarning": "Utilisation dépassée les nouvelles limites de plan", "billingLimitViolationDescription": "Votre utilisation actuelle dépasse les limites de ce plan. Après rétrogradation, toutes les actions seront désactivées jusqu'à ce que vous réduisiez l'utilisation dans les nouvelles limites. Veuillez consulter les fonctionnalités ci-dessous qui dépassent actuellement les limites. Limites en violation :", "billingFeatureLossWarning": "Avis de disponibilité des fonctionnalités", "billingFeatureLossDescription": "En rétrogradant, les fonctionnalités non disponibles dans le nouveau plan seront automatiquement désactivées. Certains paramètres et configurations peuvent être perdus. Veuillez consulter la matrice de prix pour comprendre quelles fonctionnalités ne seront plus disponibles.", "billingUsageExceedsLimit": "L'utilisation actuelle ({current}) dépasse la limite ({limit})", "billingPastDueTitle": "Paiement en retard", "billingPastDueDescription": "Votre paiement est échu. Veuillez mettre à jour votre méthode de paiement pour continuer à utiliser les fonctionnalités de votre plan actuel. Si non résolu, votre abonnement sera annulé et vous serez remis au niveau gratuit.", "billingUnpaidTitle": "Abonnement impayé", "billingUnpaidDescription": "Votre abonnement est impayé et vous avez été reversé au niveau gratuit. Veuillez mettre à jour votre méthode de paiement pour restaurer votre abonnement.", "billingIncompleteTitle": "Paiement incomplet", "billingIncompleteDescription": "Votre paiement est incomplet. Veuillez compléter le processus de paiement pour activer votre abonnement.", "billingIncompleteExpiredTitle": "Paiement expiré", "billingIncompleteExpiredDescription": "Votre paiement n'a jamais été complété et a expiré. Vous avez été restauré au niveau gratuit. Veuillez vous abonner à nouveau pour restaurer l'accès aux fonctionnalités payantes.", "billingManageSubscription": "Gérer votre abonnement", "billingResolvePaymentIssue": "Veuillez résoudre votre problème de paiement avant de procéder à la mise à niveau ou à la rétrogradation", "signUpTerms": { "IAgreeToThe": "Je suis d'accord avec", "termsOfService": "les conditions d'utilisation", "and": "et", "privacyPolicy": "politique de confidentialité." }, "signUpMarketing": { "keepMeInTheLoop": "Gardez-moi dans la boucle avec des nouvelles, des mises à jour et de nouvelles fonctionnalités par courriel." }, "siteRequired": "Le site est requis.", "olmTunnel": "Tunnel Olm", "olmTunnelDescription": "Utilisez Olm pour la connectivité client", "errorCreatingClient": "Erreur lors de la création du client", "clientDefaultsNotFound": "Les paramètres par défaut du client sont introuvables", "createClient": "Créer un client", "createClientDescription": "Créer un nouveau client pour accéder aux ressources privées", "seeAllClients": "Voir tous les clients", "clientInformation": "Informations client", "clientNamePlaceholder": "Nom du client", "address": "Adresse", "subnetPlaceholder": "Sous-réseau", "addressDescription": "L'adresse interne du client. Doit être dans le sous-réseau de l'organisation.", "selectSites": "Sélectionner des sites", "sitesDescription": "Le client aura une connectivité vers les sites sélectionnés", "clientInstallOlm": "Installer Olm", "clientInstallOlmDescription": "Faites fonctionner Olm sur votre système", "clientOlmCredentials": "Identifiants", "clientOlmCredentialsDescription": "C'est ainsi que le client s'authentifie avec le serveur", "olmEndpoint": "Endpoint", "olmId": "ID", "olmSecretKey": "Secrète", "clientCredentialsSave": "Enregistrer les informations d'identification", "clientCredentialsSaveDescription": "Vous ne pourrez voir cela qu'une seule fois. Assurez-vous de la copier dans un endroit sécurisé.", "generalSettingsDescription": "Configurez les paramètres généraux pour ce client", "clientUpdated": "Client mis à jour", "clientUpdatedDescription": "Le client a été mis à jour.", "clientUpdateFailed": "Échec de la mise à jour du client", "clientUpdateError": "Une erreur s'est produite lors de la mise à jour du client.", "sitesFetchFailed": "Échec de la récupération des sites", "sitesFetchError": "Une erreur s'est produite lors de la récupération des sites.", "olmErrorFetchReleases": "Une erreur s'est produite lors de la récupération des versions d'Olm.", "olmErrorFetchLatest": "Une erreur s'est produite lors de la récupération de la dernière version d'Olm.", "enterCidrRange": "Entrez la plage CIDR", "resourceEnableProxy": "Activer le proxy public", "resourceEnableProxyDescription": "Activez le proxy public vers cette ressource. Cela permet d'accéder à la ressource depuis l'extérieur du réseau via le cloud sur un port ouvert. Nécessite la configuration de Traefik.", "externalProxyEnabled": "Proxy externe activé", "addNewTarget": "Ajouter une nouvelle cible", "targetsList": "Liste des cibles", "advancedMode": "Mode Avancé", "advancedSettings": "Paramètres avancés", "targetErrorDuplicateTargetFound": "Cible en double trouvée", "healthCheckHealthy": "Sain", "healthCheckUnhealthy": "En mauvaise santé", "healthCheckUnknown": "Inconnu", "healthCheck": "Vérification de l'état de santé", "configureHealthCheck": "Configurer la vérification de l'état de santé", "configureHealthCheckDescription": "Configurer la surveillance de la santé pour {target}", "enableHealthChecks": "Activer les vérifications de santé", "enableHealthChecksDescription": "Surveiller la vie de cette cible. Vous pouvez surveiller un point de terminaison différent de la cible si nécessaire.", "healthScheme": "Méthode", "healthSelectScheme": "Sélectionnez la méthode", "healthCheckPortInvalid": "Le port du bilan de santé doit être compris entre 1 et 65535", "healthCheckPath": "Chemin d'accès", "healthHostname": "IP / Hôte", "healthPort": "Port", "healthCheckPathDescription": "Le chemin à vérifier pour le statut de santé.", "healthyIntervalSeconds": "Intervalle de santé (sec)", "unhealthyIntervalSeconds": "Intervalle malsain (sec)", "IntervalSeconds": "Intervalle sain", "timeoutSeconds": "Délai d'attente (sec)", "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 séparées par des virgules que l'utilisateur est autorisé à exécuter avec sudo.", "sshCreateHomeDir": "Créer un répertoire personnel", "sshUnixGroups": "Groupes Unix", "sshUnixGroupsDescription": "Groupes Unix séparés par des virgules pour 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.", "customHeaders": "En-têtes personnalisés", "customHeadersDescription": "En-têtes séparés par une nouvelle ligne: En-nom: valeur", "headersValidationError": "Les entêtes doivent être au format : Header-Name: valeur.", "saveHealthCheck": "Sauvegarder la vérification de l'état de santé", "healthCheckSaved": "Vérification de l'état de santé enregistrée", "healthCheckSavedDescription": "La configuration de la vérification de l'état de santé a été enregistrée avec succès", "healthCheckError": "Erreur de vérification de l'état de santé", "healthCheckErrorDescription": "Une erreur s'est produite lors de l'enregistrement de la configuration de la vérification de l'état de santé", "healthCheckPathRequired": "Le chemin de vérification de l'état de santé est requis", "healthCheckMethodRequired": "La méthode HTTP est requise", "healthCheckIntervalMin": "L'intervalle de vérification doit être d'au moins 5 secondes", "healthCheckTimeoutMin": "Le délai doit être d'au moins 1 seconde", "healthCheckRetryMin": "Les tentatives de réessai doivent être d'au moins 1", "httpMethod": "Méthode HTTP", "selectHttpMethod": "Sélectionnez la méthode HTTP", "domainPickerSubdomainLabel": "Sous-domaine", "domainPickerBaseDomainLabel": "Domaine de base", "domainPickerSearchDomains": "Rechercher des domaines...", "domainPickerNoDomainsFound": "Aucun domaine trouvé", "domainPickerLoadingDomains": "Chargement des domaines...", "domainPickerSelectBaseDomain": "Sélectionnez le domaine de base...", "domainPickerNotAvailableForCname": "Non disponible pour les domaines CNAME", "domainPickerEnterSubdomainOrLeaveBlank": "Entrez un sous-domaine ou laissez vide pour utiliser le domaine de base.", "domainPickerEnterSubdomainToSearch": "Entrez un sous-domaine pour rechercher et sélectionner parmi les domaines gratuits disponibles.", "domainPickerFreeDomains": "Domaines gratuits", "domainPickerSearchForAvailableDomains": "Rechercher des domaines disponibles", "domainPickerNotWorkSelfHosted": "Remarque : Les domaines fournis gratuitement ne sont pas disponibles pour les instances auto-hébergées pour le moment.", "resourceDomain": "Domaine", "resourceEditDomain": "Modifier le domaine", "siteName": "Nom du site", "proxyPort": "Port", "resourcesTableProxyResources": "Publique", "resourcesTableClientResources": "Privé", "resourcesTableNoProxyResourcesFound": "Aucune ressource proxy trouvée.", "resourcesTableNoInternalResourcesFound": "Aucune ressource interne trouvée.", "resourcesTableDestination": "Destination", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Adresse de l'alias", "resourcesTableAliasAddressInfo": "Cette adresse fait partie du sous-réseau utilitaire de l'organisation. Elle est utilisée pour résoudre les enregistrements d'alias en utilisant une résolution DNS interne.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "et sont uniquement accessibles en interne lorsqu'elles sont connectées avec un client.", "resourcesTableNoTargets": "Aucune cible", "resourcesTableHealthy": "Sain", "resourcesTableDegraded": "Dégradé", "resourcesTableOffline": "Hors ligne", "resourcesTableUnknown": "Inconnu", "resourcesTableNotMonitored": "Non-monitoré", "editInternalResourceDialogEditClientResource": "Modifier une ressource privée", "editInternalResourceDialogUpdateResourceProperties": "Mettre à jour la configuration de la ressource et les contrôles d'accès pour {resourceName}", "editInternalResourceDialogResourceProperties": "Propriétés de la ressource", "editInternalResourceDialogName": "Nom", "editInternalResourceDialogProtocol": "Protocole", "editInternalResourceDialogSitePort": "Port du site", "editInternalResourceDialogTargetConfiguration": "Configuration de la cible", "editInternalResourceDialogCancel": "Abandonner", "editInternalResourceDialogSaveResource": "Enregistrer la ressource", "editInternalResourceDialogSuccess": "Succès", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Ressource interne mise à jour avec succès", "editInternalResourceDialogError": "Erreur", "editInternalResourceDialogFailedToUpdateInternalResource": "Échec de la mise à jour de la ressource interne", "editInternalResourceDialogNameRequired": "Le nom est requis", "editInternalResourceDialogNameMaxLength": "Le nom doit être inférieur à 255 caractères", "editInternalResourceDialogProxyPortMin": "Le port proxy doit être d'au moins 1", "editInternalResourceDialogProxyPortMax": "Le port proxy doit être inférieur à 65536", "editInternalResourceDialogInvalidIPAddressFormat": "Format d'adresse IP invalide", "editInternalResourceDialogDestinationPortMin": "Le port de destination doit être d'au moins 1", "editInternalResourceDialogDestinationPortMax": "Le port de destination doit être inférieur à 65536", "editInternalResourceDialogPortModeRequired": "Protocole, port proxy et port de destination sont requis pour le mode port", "editInternalResourceDialogMode": "Mode", "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Hôte", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Destination", "editInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.", "editInternalResourceDialogDestinationIPDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.", "editInternalResourceDialogDestinationCidrDescription": "La gamme CIDR de la ressource sur le réseau du site.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Un alias DNS interne optionnel pour cette ressource.", "createInternalResourceDialogNoSitesAvailable": "Aucun site disponible", "createInternalResourceDialogNoSitesAvailableDescription": "Vous devez avoir au moins un site Newt avec un sous-réseau configuré pour créer des ressources internes.", "createInternalResourceDialogClose": "Fermer", "createInternalResourceDialogCreateClientResource": "Créer une ressource privée", "createInternalResourceDialogCreateClientResourceDescription": "Créer une nouvelle ressource qui ne sera accessible qu'aux clients connectés à l'organisation", "createInternalResourceDialogResourceProperties": "Propriétés de la ressource", "createInternalResourceDialogName": "Nom", "createInternalResourceDialogSite": "Site", "selectSite": "Sélectionner un site...", "noSitesFound": "Aucun site trouvé.", "createInternalResourceDialogProtocol": "Protocole", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Port du site", "createInternalResourceDialogSitePortDescription": "Utilisez ce port pour accéder à la ressource sur le site lors de la connexion avec un client.", "createInternalResourceDialogTargetConfiguration": "Configuration de la cible", "createInternalResourceDialogDestinationIPDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.", "createInternalResourceDialogDestinationPortDescription": "Le port sur l'IP de destination où la ressource est accessible.", "createInternalResourceDialogCancel": "Abandonner", "createInternalResourceDialogCreateResource": "Créer une ressource", "createInternalResourceDialogSuccess": "Succès", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Ressource interne créée avec succès", "createInternalResourceDialogError": "Erreur", "createInternalResourceDialogFailedToCreateInternalResource": "Échec de la création de la ressource interne", "createInternalResourceDialogNameRequired": "Le nom est requis", "createInternalResourceDialogNameMaxLength": "Le nom doit être inférieur à 255 caractères", "createInternalResourceDialogPleaseSelectSite": "Veuillez sélectionner un site", "createInternalResourceDialogProxyPortMin": "Le port proxy doit être d'au moins 1", "createInternalResourceDialogProxyPortMax": "Le port proxy doit être inférieur à 65536", "createInternalResourceDialogInvalidIPAddressFormat": "Format d'adresse IP invalide", "createInternalResourceDialogDestinationPortMin": "Le port de destination doit être d'au moins 1", "createInternalResourceDialogDestinationPortMax": "Le port de destination doit être inférieur à 65536", "createInternalResourceDialogPortModeRequired": "Protocole, port proxy et port de destination sont requis pour le mode port", "createInternalResourceDialogMode": "Mode", "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Hôte", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Destination", "createInternalResourceDialogDestinationHostDescription": "L'adresse IP ou le nom d'hôte de la ressource sur le réseau du site.", "createInternalResourceDialogDestinationCidrDescription": "La gamme CIDR de la ressource sur le réseau du site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interne optionnel pour cette ressource.", "siteConfiguration": "Configuration", "siteAcceptClientConnections": "Accepter les connexions client", "siteAcceptClientConnectionsDescription": "Autoriser les utilisateurs et les clients à accéder aux ressources de ce site. Cela peut être modifié plus tard.", "siteAddress": "Adresse du site (Avancé)", "siteAddressDescription": "L'adresse interne du site. Doit être dans le sous-réseau de l'organisation.", "siteNameDescription": "Le nom d'affichage du site qui peut être modifié plus tard.", "autoLoginExternalIdp": "Connexion automatique avec IDP externe", "autoLoginExternalIdpDescription": "Redirigez immédiatement l'utilisateur vers le fournisseur d'identité externe pour l'authentification.", "selectIdp": "Sélectionner l'IDP", "selectIdpPlaceholder": "Choisissez un IDP...", "selectIdpRequired": "Veuillez sélectionner un IDP lorsque la connexion automatique est activée.", "autoLoginTitle": "Redirection", "autoLoginDescription": "Redirection vers le fournisseur d'identité externe pour l'authentification.", "autoLoginProcessing": "Préparation de l'authentification...", "autoLoginRedirecting": "Redirection vers la connexion...", "autoLoginError": "Erreur de connexion automatique", "autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.", "autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification.", "remoteExitNodeManageRemoteExitNodes": "Nœuds distants", "remoteExitNodeDescription": "Hébergez vous-même vos propres nœuds de relais et de serveur proxy distants", "remoteExitNodes": "Nœuds", "searchRemoteExitNodes": "Rechercher des nœuds...", "remoteExitNodeAdd": "Ajouter un noeud", "remoteExitNodeErrorDelete": "Erreur lors de la suppression du noeud", "remoteExitNodeQuestionRemove": "Êtes-vous sûr de vouloir supprimer le noeud de l'organisation ?", "remoteExitNodeMessageRemove": "Une fois supprimé, le noeud ne sera plus accessible.", "remoteExitNodeConfirmDelete": "Confirmer la suppression du noeud", "remoteExitNodeDelete": "Supprimer le noeud", "sidebarRemoteExitNodes": "Nœuds distants", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Clé secrète", "remoteExitNodeCreate": { "title": "Créer un nœud distant", "description": "Créez un nouveau nœud de relais et de serveur proxy distant auto-hébergé", "viewAllButton": "Voir tous les nœuds", "strategy": { "title": "Stratégie de création", "description": "Sélectionnez comment vous souhaitez créer le nœud distant", "adopt": { "title": "Adopter un nœud", "description": "Choisissez ceci si vous avez déjà les identifiants pour le noeud." }, "generate": { "title": "Générer des clés", "description": "Choisissez ceci si vous voulez générer de nouvelles clés pour le noeud." } }, "adopt": { "title": "Adopter un nœud existant", "description": "Entrez les identifiants du noeud existant que vous souhaitez adopter", "nodeIdLabel": "Nœud ID", "nodeIdDescription": "L'ID du noeud existant que vous voulez adopter", "secretLabel": "Secret", "secretDescription": "La clé secrète du noeud existant", "submitButton": "Noeud d'Adopt" }, "generate": { "title": "Informations d'identification générées", "description": "Utilisez ces identifiants générés pour configurer le noeud", "nodeIdTitle": "Nœud ID", "secretTitle": "Secret", "saveCredentialsTitle": "Ajouter des identifiants à la config", "saveCredentialsDescription": "Ajoutez ces informations d'identification à votre fichier de configuration du nœud Pangolin auto-hébergé pour compléter la connexion.", "submitButton": "Créer un noeud" }, "validation": { "adoptRequired": "ID de nœud et secret sont requis lors de l'adoption d'un noeud existant" }, "errors": { "loadDefaultsFailed": "Échec du chargement des valeurs par défaut", "defaultsNotLoaded": "Valeurs par défaut non chargées", "createFailed": "Impossible de créer le noeud" }, "success": { "created": "Noeud créé avec succès" } }, "remoteExitNodeSelection": "Sélection du noeud", "remoteExitNodeSelectionDescription": "Sélectionnez un nœud pour acheminer le trafic pour ce site local", "remoteExitNodeRequired": "Un noeud doit être sélectionné pour les sites locaux", "noRemoteExitNodesAvailable": "Aucun noeud disponible", "noRemoteExitNodesAvailableDescription": "Aucun noeud n'est disponible pour cette organisation. Créez d'abord un noeud pour utiliser des sites locaux.", "exitNode": "Nœud de sortie", "country": "Pays", "rulesMatchCountry": "Actuellement basé sur l'IP source", "managedSelfHosted": { "title": "Gestion autonome", "description": "Serveur Pangolin auto-hébergé avec des cloches et des sifflets supplémentaires", "introTitle": "Pangolin auto-hébergé géré", "introDescription": "est une option de déploiement conçue pour les personnes qui veulent de la simplicité et de la fiabilité tout en gardant leurs données privées et auto-hébergées.", "introDetail": "Avec cette option, vous exécutez toujours votre propre nœud Pangolin — vos tunnels, la terminaison SSL et le trafic restent sur votre serveur. La différence est que la gestion et la surveillance sont gérées via notre tableau de bord du cloud, qui déverrouille un certain nombre d'avantages :", "benefitSimplerOperations": { "title": "Opérations plus simples", "description": "Pas besoin de faire tourner votre propre serveur de messagerie ou de configurer des alertes complexes. Vous obtiendrez des contrôles de santé et des alertes de temps d'arrêt par la suite." }, "benefitAutomaticUpdates": { "title": "Mises à jour automatiques", "description": "Le tableau de bord du cloud évolue rapidement, de sorte que vous obtenez de nouvelles fonctionnalités et des corrections de bugs sans avoir à extraire manuellement de nouveaux conteneurs à chaque fois." }, "benefitLessMaintenance": { "title": "Moins de maintenance", "description": "Aucune migration de base de données, sauvegarde ou infrastructure supplémentaire à gérer. Nous gérons cela dans le cloud." }, "benefitCloudFailover": { "title": "Basculement du Cloud", "description": "Si votre nœud descend, vos tunnels peuvent temporairement échouer jusqu'à ce que vous le rapatriez en ligne." }, "benefitHighAvailability": { "title": "Haute disponibilité (PoPs)", "description": "Vous pouvez également attacher plusieurs nœuds à votre compte pour une redondance et de meilleures performances." }, "benefitFutureEnhancements": { "title": "Améliorations futures", "description": "Nous prévoyons d'ajouter plus d'outils d'analyse, d'alerte et de gestion pour rendre votre déploiement encore plus robuste." }, "docsAlert": { "text": "En savoir plus sur l'option Auto-Hébergement géré dans notre", "documentation": "documentation" }, "convertButton": "Convertir ce noeud en auto-hébergé géré" }, "internationaldomaindetected": "Domaine international détecté", "willbestoredas": "Sera stocké comme :", "roleMappingDescription": "Détermine comment les rôles sont assignés aux utilisateurs lorsqu'ils se connectent lorsque la fourniture automatique est activée.", "selectRole": "Sélectionnez un rôle", "roleMappingExpression": "Expression", "selectRolePlaceholder": "Choisir un rôle", "selectRoleDescription": "Sélectionnez un rôle à assigner à tous les utilisateurs de ce fournisseur d'identité", "roleMappingExpressionDescription": "Entrez une expression JMESPath pour extraire les informations du rôle du jeton ID", "idpTenantIdRequired": "L'ID du locataire est requis", "invalidValue": "Valeur non valide", "idpTypeLabel": "Type de fournisseur d'identité", "roleMappingExpressionPlaceholder": "ex: contenu(groupes) && 'admin' || 'membre'", "idpGoogleConfiguration": "Configuration Google", "idpGoogleConfigurationDescription": "Configurer les identifiants Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Secret client OAuth2 de Google", "idpAzureConfiguration": "Configuration de l'entra ID Azure", "idpAzureConfigurationDescription": "Configurer les identifiants OAuth2 Azure Entra ID", "idpTenantId": "ID du locataire", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "ID du locataire Azure (trouvé dans l'aperçu Azure Active Directory)", "idpAzureClientIdDescription": "ID client d'enregistrement de l'application Azure", "idpAzureClientSecretDescription": "Secret du client d'enregistrement de l'application Azure", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Configuration Google", "idpAzureConfigurationTitle": "Configuration de l'entra ID Azure", "idpTenantIdLabel": "ID du locataire", "idpAzureClientIdDescription2": "ID client d'enregistrement de l'application Azure", "idpAzureClientSecretDescription2": "Secret du client d'enregistrement de l'application Azure", "idpGoogleDescription": "Fournisseur Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sous-réseau", "subnetDescription": "Le sous-réseau de la configuration réseau de cette organisation.", "customDomain": "Domaine personnalisé", "authPage": "Pages d'authentification", "authPageDescription": "Définissez un domaine personnalisé pour les pages d'authentification de l'organisation", "authPageDomain": "Domaine de la page d'authentification", "authPageBranding": "Marque personnalisée", "authPageBrandingDescription": "Configurez la marque qui apparaît sur les pages d'authentification pour cette organisation", "authPageBrandingUpdated": "Marque de la page d'authentification mise à jour avec succès", "authPageBrandingRemoved": "Marque de la page d'authentification supprimée avec succès", "authPageBrandingRemoveTitle": "Supprimer la marque de la page d'authentification", "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)", "brandingOrgTitle": "Titre pour la page d'authentification de l'organisation", "brandingOrgDescription": "{orgName} sera remplacé par le nom de l'organisation", "brandingOrgSubtitle": "Sous-titre pour la page d'authentification de l'organisation", "brandingResourceTitle": "Titre pour la page d'authentification de la ressource", "brandingResourceSubtitle": "Sous-titre pour la page d'authentification de la ressource", "brandingResourceDescription": "{resourceName} sera remplacé par le nom de l'organisation", "saveAuthPageDomain": "Enregistrer le domaine", "saveAuthPageBranding": "Enregistrer la marque", "removeAuthPageBranding": "Supprimer la marque", "noDomainSet": "Aucun domaine défini", "changeDomain": "Changer de domaine", "selectDomain": "Sélectionner un domaine", "restartCertificate": "Redémarrer le certificat", "editAuthPageDomain": "Modifier le domaine de la page d'authentification", "setAuthPageDomain": "Définir le domaine de la page d'authentification", "failedToFetchCertificate": "Impossible de récupérer le certificat", "failedToRestartCertificate": "Échec du redémarrage du certificat", "addDomainToEnableCustomAuthPages": "Les utilisateurs pourront accéder à la page de connexion de l'organisation et compléter l'authentification de la ressource en utilisant ce domaine.", "selectDomainForOrgAuthPage": "Sélectionnez un domaine pour la page d'authentification de l'organisation", "domainPickerProvidedDomain": "Domaine fourni", "domainPickerFreeProvidedDomain": "Domaine fourni gratuitement", "domainPickerVerified": "Vérifié", "domainPickerUnverified": "Non vérifié", "domainPickerInvalidSubdomainStructure": "Ce sous-domaine contient des caractères ou une structure non valide. Il sera automatiquement nettoyé lorsque vous enregistrez.", "domainPickerError": "Erreur", "domainPickerErrorLoadDomains": "Impossible de charger les domaines de l'organisation", "domainPickerErrorCheckAvailability": "Impossible de vérifier la disponibilité du domaine", "domainPickerInvalidSubdomain": "Sous-domaine invalide", "domainPickerInvalidSubdomainRemoved": "L'entrée \"{sub}\" a été supprimée car elle n'est pas valide.", "domainPickerInvalidSubdomainCannotMakeValid": "La «{sub}» n'a pas pu être validée pour {domain}.", "domainPickerSubdomainSanitized": "Sous-domaine nettoyé", "domainPickerSubdomainCorrected": "\"{sub}\" a été corrigé à \"{sanitized}\"", "orgAuthSignInTitle": "Connexion à l'organisation", "orgAuthChooseIdpDescription": "Choisissez votre fournisseur d'identité pour continuer", "orgAuthNoIdpConfigured": "Cette organisation n'a aucun fournisseur d'identité configuré. Vous pouvez vous connecter avec votre identité Pangolin à la place.", "orgAuthSignInWithPangolin": "Se connecter avec Pangolin", "orgAuthSignInToOrg": "Se connecter à une organisation", "orgAuthSelectOrgTitle": "Connexion à l'organisation", "orgAuthSelectOrgDescription": "Entrez votre identifiant d'organisation pour continuer", "orgAuthOrgIdPlaceholder": "votre-organisation", "orgAuthOrgIdHelp": "Entrez l'identifiant unique de votre organisation", "orgAuthSelectOrgHelp": "Après avoir entré votre identifiant d'organisation, vous serez dirigé vers la page de connexion de votre organisation où vous pourrez utiliser l'authentification unique (SSO) ou vos identifiants d'organisation.", "orgAuthRememberOrgId": "Mémoriser cet identifiant d'organisation", "orgAuthBackToSignIn": "Retour à la connexion standard", "orgAuthNoAccount": "Vous n'avez pas de compte ?", "subscriptionRequiredToUse": "Un abonnement est requis pour utiliser cette fonctionnalité.", "mustUpgradeToUse": "Vous devez mettre à jour votre abonnement pour utiliser cette fonctionnalité.", "subscriptionRequiredTierToUse": "Cette fonctionnalité nécessite {tier} ou supérieur.", "upgradeToTierToUse": "Passez à {tier} ou plus pour utiliser cette fonctionnalité.", "subscriptionTierTier1": "Domicile", "subscriptionTierTier2": "Equipe", "subscriptionTierTier3": "Entreprise", "subscriptionTierEnterprise": "Entreprise", "idpDisabled": "Les fournisseurs d'identité sont désactivés.", "orgAuthPageDisabled": "La page d'authentification de l'organisation est désactivée.", "domainRestartedDescription": "La vérification du domaine a été redémarrée avec succès", "resourceAddEntrypointsEditFile": "Modifier le fichier : config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Modifier le fichier : docker-compose.yml", "emailVerificationRequired": "La vérification de l'e-mail est requise. Veuillez vous reconnecter via {dashboardUrl}/auth/login terminé cette étape. Puis revenez ici.", "twoFactorSetupRequired": "La configuration d'authentification à deux facteurs est requise. Veuillez vous reconnecter via {dashboardUrl}/auth/login terminé cette étape. Puis revenez ici.", "additionalSecurityRequired": "Sécurité supplémentaire requise", "organizationRequiresAdditionalSteps": "Cette organisation nécessite des étapes de sécurité supplémentaires avant de pouvoir accéder aux ressources.", "completeTheseSteps": "Compléter ces étapes", "enableTwoFactorAuthentication": "Activer l'authentification à deux facteurs", "completeSecuritySteps": "Compléter les étapes de sécurité", "securitySettings": "Paramètres de sécurité", "dangerSection": "Zone dangereuse", "dangerSectionDescription": "Supprimez définitivement toutes les données associées à cette organisation", "securitySettingsDescription": "Configurer les politiques de sécurité de l'organisation", "requireTwoFactorForAllUsers": "Exiger une authentification à deux facteurs pour tous les utilisateurs", "requireTwoFactorDescription": "Lorsque cette option est activée, tous les utilisateurs internes de cette organisation doivent avoir l'authentification à deux facteurs pour accéder à l'organisation.", "requireTwoFactorDisabledDescription": "Cette fonctionnalité nécessite une licence valide (Entreprise) ou un abonnement actif (SaaS)", "requireTwoFactorCannotEnableDescription": "Vous devez activer l'authentification à deux facteurs pour votre compte avant de l'appliquer pour tous les utilisateurs", "maxSessionLength": "Longueur maximale de la session", "maxSessionLengthDescription": "Définissez la durée maximale des sessions utilisateur. Après cette période, les utilisateurs devront se ré-authentifier.", "maxSessionLengthDisabledDescription": "Cette fonctionnalité nécessite une licence valide (Entreprise) ou un abonnement actif (SaaS)", "selectSessionLength": "Sélectionnez la durée de la session", "unenforced": "Non appliqué", "1Hour": "1 heure", "3Hours": "3heures", "6Hours": "6 heures", "12Hours": "12 heures", "1DaySession": "1 jour", "3Days": "3 jours", "7Days": "7 jours", "14Days": "14 jours", "30DaysSession": "30 jours", "90DaysSession": "90 jours", "180DaysSession": "180 jours", "passwordExpiryDays": "Expiration du mot de passe", "editPasswordExpiryDescription": "Définissez le nombre de jours avant que les utilisateurs ne soient tenus de changer leur mot de passe.", "selectPasswordExpiry": "Sélectionnez l'expiration du mot de passe", "30Days": "30 jours", "1Day": "1 jour", "60Days": "60 jours", "90Days": "90 jours", "180Days": "180 jours", "1Year": "1 an", "subscriptionBadge": "Abonnement Requis", "securityPolicyChangeWarning": "Avertissement de changement de politique de sécurité", "securityPolicyChangeDescription": "Vous êtes sur le point de modifier les paramètres de la politique de sécurité. Une fois enregistré, vous devrez peut-être vous authentifier à nouveau pour vous conformer à ces mises à jour de règles. Tous les utilisateurs qui ne sont pas conformes devront également se réauthentifier.", "securityPolicyChangeConfirmMessage": "Je confirme", "securityPolicyChangeWarningText": "Cela affectera tous les utilisateurs de l'organisation", "authPageErrorUpdateMessage": "Une erreur s'est produite lors de la mise à jour de la page d\u000027authentification", "authPageErrorUpdate": "Impossible de mettre à jour la page d'authentification", "authPageDomainUpdated": "Domaine de la page d'authentification mis à jour avec succès", "healthCheckNotAvailable": "Locale", "rewritePath": "Réécrire le chemin", "rewritePathDescription": "Réécrivez éventuellement le chemin avant de le transmettre à la cible.", "continueToApplication": "Continuer vers l'application", "checkingInvite": "Vérification de l'invitation", "setResourceHeaderAuth": "Définir l\\'authentification d\\'en-tête de la ressource", "resourceHeaderAuthRemove": "Supprimer l'authentification de l'en-tête", "resourceHeaderAuthRemoveDescription": "Authentification de l'en-tête supprimée avec succès.", "resourceErrorHeaderAuthRemove": "Échec de la suppression de l'authentification de l'en-tête", "resourceErrorHeaderAuthRemoveDescription": "Impossible de supprimer l'authentification de l'en-tête de la ressource.", "resourceHeaderAuthProtectionEnabled": "Authentification de l'en-tête activée", "resourceHeaderAuthProtectionDisabled": "L'authentification de l'en-tête est désactivée", "headerAuthRemove": "Supprimer l'authentification de l'en-tête", "headerAuthAdd": "Ajouter l'authentification de l'en-tête", "resourceErrorHeaderAuthSetup": "Impossible de définir l'authentification de l'en-tête", "resourceErrorHeaderAuthSetupDescription": "Impossible de définir l'authentification de l'en-tête pour la ressource.", "resourceHeaderAuthSetup": "Authentification de l'en-tête définie avec succès", "resourceHeaderAuthSetupDescription": "L'authentification de l'en-tête a été définie avec succès.", "resourceHeaderAuthSetupTitle": "Authentification de l'en-tête", "resourceHeaderAuthSetupTitleDescription": "Définissez les identifiants d'authentification de base (nom d'utilisateur et mot de passe) pour protéger cette ressource avec l'authentification de l'en-tête HTTP. Accédez-y en utilisant le format https://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Authentification de l'en-tête", "actionSetResourceHeaderAuth": "Authentification de l'en-tête", "enterpriseEdition": "Édition Entreprise", "unlicensed": "Sans licence", "beta": "Bêta", "manageUserDevices": "Périphériques utilisateur", "manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources", "downloadClientBannerTitle": "Télécharger le client Pangolin", "downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.", "manageMachineClients": "Gérer les clients de la machine", "manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources", "machineClientsBannerTitle": "Serveurs & Systèmes automatisés", "machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Conteneur Olm", "clientsTableUserClients": "Utilisateur", "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Valable jusqu'au", "saasLicenseKeysSettingsTitle": "Licences Entreprise", "saasLicenseKeysSettingsDescription": "Générer et gérer les clés de licence Entreprise pour les instances Pangolin auto-hébergées", "sidebarEnterpriseLicenses": "Licences", "generateLicenseKey": "Générer une clé de licence", "generateLicenseKeyForm": { "validation": { "emailRequired": "Veuillez entrer une adresse e-mail valide", "useCaseTypeRequired": "Veuillez sélectionner un type de cas d'utilisation", "firstNameRequired": "Le prénom est requis", "lastNameRequired": "Le nom est requis", "primaryUseRequired": "Veuillez décrire votre utilisation principale", "jobTitleRequiredBusiness": "Le titre du poste est requis pour un usage professionnel", "industryRequiredBusiness": "L'industrie est requise pour une utilisation commerciale", "stateProvinceRegionRequired": "État/Province/Région est obligatoire", "postalZipCodeRequired": "Le code postal est requis", "companyNameRequiredBusiness": "Le nom de la société est requis pour une utilisation commerciale", "countryOfResidenceRequiredBusiness": "Le pays de résidence est requis pour un usage professionnel", "countryRequiredPersonal": "Le pays est requis pour un usage personnel", "agreeToTermsRequired": "Vous devez accepter les conditions", "complianceConfirmationRequired": "Vous devez confirmer le respect de la licence commerciale Fossorial" }, "useCaseOptions": { "personal": { "title": "Utilisation personnelle", "description": "Pour une utilisation individuelle et non commerciale telle que l'apprentissage, les projets personnels ou l'expérimentation." }, "business": { "title": "Utilisation de l'entreprise", "description": "Pour utilisation au sein d’organisations, d’entreprises ou d’activités commerciales ou génératrices de revenus." } }, "steps": { "emailLicenseType": { "title": "Email & Type de licence", "description": "Entrez votre adresse e-mail et choisissez votre type de licence" }, "personalInformation": { "title": "Informations personnelles", "description": "Parlez-nous de vous-même" }, "contactInformation": { "title": "Coordonnées", "description": "Vos coordonnées" }, "termsGenerate": { "title": "Termes & Générer", "description": "Examinez et acceptez les conditions pour générer votre licence" } }, "alerts": { "commercialUseDisclosure": { "title": "Divulgation d'utilisation", "description": "Sélectionnez le niveau de licence qui correspond exactement à votre utilisation prévue. La Licence Personnelle autorise l'utilisation libre du Logiciel pour des activités commerciales individuelles, non commerciales ou à petite échelle avec un revenu annuel brut inférieur à 100 000 USD. Toute utilisation au-delà de ces limites — y compris l'utilisation au sein d'une entreprise, d'une organisation, ou tout autre environnement générateur de revenus — nécessite une licence d’entreprise valide et le paiement des droits de licence applicables. Tous les utilisateurs, qu'ils soient personnels ou d'entreprise, doivent se conformer aux conditions de licence commerciale Fossorial." }, "trialPeriodInformation": { "title": "Informations sur la période d'essai", "description": "Cette clé de licence permet aux entreprises de bénéficier de fonctionnalités pour une période d'évaluation de 7 jours. L'accès continu aux fonctionnalités payantes au-delà de la période d'évaluation nécessite une activation sous une licence personnelle ou d'entreprise valide. Pour une licence d'entreprise, contactez sales@pangolin.net." } }, "form": { "useCaseQuestion": "Utilisez-vous Pangolin à des fins personnelles ou professionnelles?", "firstName": "Prénom", "lastName": "Nom de famille", "jobTitle": "Titre du poste", "primaryUseQuestion": "À quoi comptez-vous avant tout utiliser le Pangolin ?", "industryQuestion": "Quelle est votre industrie?", "prospectiveUsersQuestion": "Combien d'utilisateurs vous attendez-vous à avoir ?", "prospectiveSitesQuestion": "Combien de sites potentiels (tunnels) voulez-vous avoir?", "companyName": "Nom de la société", "countryOfResidence": "Pays de résidence", "stateProvinceRegion": "Etat / Province / Région", "postalZipCode": "Code postal / ZIP", "companyWebsite": "Site web de l'entreprise", "companyPhoneNumber": "Numéro de téléphone de la société", "country": "Pays", "phoneNumberOptional": "Numéro de téléphone (facultatif)", "complianceConfirmation": "Je confirme que les renseignements que j'ai fournis sont exacts et que je suis en conformité avec la licence commerciale Fossorial. Signaler des informations inexactes ou une mauvaise identification de l'utilisation du produit constitue une violation de la licence et peut entraîner la révocation de votre clé." }, "buttons": { "close": "Fermer", "previous": "Précédent", "next": "Suivant", "generateLicenseKey": "Générer une clé de licence" }, "toasts": { "success": { "title": "Clé de licence générée avec succès", "description": "Votre clé de licence a été générée et est prête à l'emploi." }, "error": { "title": "Impossible de générer la clé de licence", "description": "Une erreur s'est produite lors de la génération de la clé de licence." } } }, "newPricingLicenseForm": { "title": "Obtenir une licence", "description": "Choisissez un plan et dites-nous comment vous comptez utiliser Pangolin.", "chooseTier": "Choisissez votre forfait", "viewPricingLink": "Voir les prix, les fonctionnalités et les limites", "tiers": { "starter": { "title": "Démarrage", "description": "Fonctionnalités d'entreprise, 25 utilisateurs, 25 sites et un support communautaire." }, "scale": { "title": "Échelle", "description": "Fonctionnalités d'entreprise, 50 utilisateurs, 50 sites et une prise en charge prioritaire." } }, "personalUseOnly": "Utilisation personnelle uniquement (licence gratuite — sans checkout)", "buttons": { "continueToCheckout": "Continuer vers le paiement" }, "toasts": { "checkoutError": { "title": "Erreur de paiement", "description": "Impossible de commencer la commande. Veuillez réessayer." } } }, "priority": "Priorité", "priorityDescription": "Les routes de haute priorité sont évaluées en premier. La priorité = 100 signifie l'ordre automatique (décision du système). Utilisez un autre nombre pour imposer la priorité manuelle.", "instanceName": "Nom de l'instance", "pathMatchModalTitle": "Configurer le chemin correspondant", "pathMatchModalDescription": "Définissez comment les requêtes entrantes doivent être trouvées en fonction de leur chemin.", "pathMatchType": "Type de correspondance", "pathMatchPrefix": "Préfixe", "pathMatchExact": "Exactement", "pathMatchRegex": "Regex", "pathMatchValue": "Valeur du chemin", "clear": "Nettoyer", "saveChanges": "Enregistrer les modifications", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/chemin d'accès", "pathMatchPrefixHelp": "Exemple: /api correspond à /api, /api/users, etc.", "pathMatchExactHelp": "Exemple: /api ne correspond qu'à /api", "pathMatchRegexHelp": "Exemple: ^/api/.* correspond à /api/anything", "pathRewriteModalTitle": "Configurer la réécriture du chemin", "pathRewriteModalDescription": "Transformez le chemin correspondant avant de l'envoyer à la cible.", "pathRewriteType": "Type de réécriture", "pathRewritePrefixOption": "Préfixe - Remplacer le préfixe", "pathRewriteExactOption": "Exactement - Remplacer le chemin entier", "pathRewriteRegexOption": "Regex - Remplacement de patron", "pathRewriteStripPrefixOption": "Retirer le préfixe - Supprimer le préfixe", "pathRewriteValue": "Réécrire la valeur", "pathRewriteRegexPlaceholder": "/fr/new/$1", "pathRewriteDefaultPlaceholder": "/fr/new-path", "pathRewritePrefixHelp": "Remplacer le préfixe correspondant par cette valeur", "pathRewriteExactHelp": "Remplacer le chemin entier par cette valeur lorsque le chemin correspond exactement", "pathRewriteRegexHelp": "Utiliser des groupes de capture comme $1, $2 pour le remplacement", "pathRewriteStripPrefixHelp": "Laisser vide pour supprimer le préfixe ou fournir un nouveau préfixe", "pathRewritePrefix": "Préfixe", "pathRewriteExact": "Exactement", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Retirer", "pathRewriteStripLabel": "bande", "sidebarEnableEnterpriseLicense": "Activer la licence Entreprise", "cannotbeUndone": "Cela ne peut pas être annulé.", "toConfirm": "pour confirmer.", "deleteClientQuestion": "Êtes-vous sûr de vouloir supprimer le client du site et de l'organisation ?", "clientMessageRemove": "Une fois supprimé, le client ne pourra plus se connecter au site.", "sidebarLogs": "Journaux", "request": "Demander", "requests": "Requêtes", "logs": "Journaux", "logsSettingsDescription": "Surveiller les journaux collectés de cette organisation", "searchLogs": "Rechercher dans les journaux...", "action": "Action", "actor": "Acteur", "timestamp": "Horodatage", "accessLogs": "Journaux d'accès", "exportCsv": "Exporter CSV", "exportError": "Erreur inconnue lors de l'exportation du CSV", "exportCsvTooltip": "Dans la plage de temps", "actorId": "ID de l'acteur", "allowedByRule": "Autorisé par la règle", "allowedNoAuth": "Aucune authentification autorisée", "validAccessToken": "Jeton d'accès valide", "validHeaderAuth": "Valid header auth", "validPincode": "Valid Pincode", "validPassword": "Mot de passe valide", "validEmail": "Valid email", "validSSO": "Valid SSO", "resourceBlocked": "Ressource bloquée", "droppedByRule": "Abandonné par la règle", "noSessions": "Aucune session", "temporaryRequestToken": "Jeton de requête temporaire", "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Raison", "requestLogs": "Journal des requêtes", "requestAnalytics": "Demander des analyses", "host": "Hôte", "location": "Localisation", "actionLogs": "Journaux des actions", "sidebarLogsRequest": "Journal des requêtes", "sidebarLogsAccess": "Journaux d'accès", "sidebarLogsAction": "Journaux des actions", "logRetention": "Journaliser la rétention", "logRetentionDescription": "Gérer la durée de conservation des différents types de logs pour cette organisation ou les désactiver", "requestLogsDescription": "Voir les journaux détaillés des requêtes pour les ressources de cette organisation", "requestAnalyticsDescription": "Voir les analyses détaillées des demandes pour les ressources de cette organisation", "logRetentionRequestLabel": "Demander la rétention des journaux", "logRetentionRequestDescription": "Durée de conservation des journaux de requêtes", "logRetentionAccessLabel": "Rétention du journal d'accès", "logRetentionAccessDescription": "Durée de conservation des journaux d'accès", "logRetentionActionLabel": "Retention du journal des actions", "logRetentionActionDescription": "Durée de conservation du journal des actions", "logRetentionDisabled": "Désactivé", "logRetention3Days": "3 jours", "logRetention7Days": "7 jours", "logRetention14Days": "14 jours", "logRetention30Days": "30 jours", "logRetention90Days": "90 jours", "logRetentionForever": "Pour toujours", "logRetentionEndOfFollowingYear": "Fin de l'année suivante", "actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation", "accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation", "licenseRequiredToUse": "Une licence Enterprise Edition est nécessaire pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans Pangolin Cloud.", "ossEnterpriseEditionRequired": "La version Enterprise Edition est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans Pangolin Cloud.", "certResolver": "Résolveur de certificat", "certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.", "selectCertResolver": "Sélectionnez le résolveur de certificat", "enterCustomResolver": "Entrez le résolveur personnalisé", "preferWildcardCert": "Préférez le certificat Wildcard", "unverified": "Non vérifié", "domainSetting": "Paramètres de domaine", "domainSettingDescription": "Configurer les paramètres du domaine", "preferWildcardCertDescription": "Tenter de générer un certificat wildcard (requiert un résolveur de certificat correctement configuré).", "recordName": "Nom de l'enregistrement", "auto": "Automatique", "TTL": "TTL", "howToAddRecords": "Comment ajouter des enregistrements", "dnsRecord": "Enregistrements DNS", "required": "Requis", "domainSettingsUpdated": "Paramètres de domaine mis à jour avec succès", "orgOrDomainIdMissing": "L'organisation ou l'identifiant de domaine est manquant", "loadingDNSRecords": "Chargement des enregistrements DNS...", "olmUpdateAvailableInfo": "Une version mise à jour de Olm est disponible. Veuillez mettre à jour vers la dernière version pour la meilleure expérience.", "client": "Client", "proxyProtocol": "Paramètres du protocole proxy", "proxyProtocolDescription": "Configurer le protocole Proxy pour préserver les adresses IP du client pour les services TCP.", "enableProxyProtocol": "Activer le protocole Proxy", "proxyProtocolInfo": "Conserver les adresses IP du client pour les backends TCP", "proxyProtocolVersion": "Version du protocole proxy", "version1": " Version 1 (Recommandé)", "version2": "Version 2", "versionDescription": "La version 1 est basée sur du texte et est largement supportée. La version 2 est binaire et plus efficace mais moins compatible.", "warning": "Avertissement", "proxyProtocolWarning": "L'application backend doit être configurée pour accepter les connexions Proxy Protocol. Si votre backend ne prend pas en charge le protocole Proxy, l'activation de cette option va perturber toutes les connexions, donc n'activez cette option que si vous savez ce que vous faites. Assurez-vous de configurer votre backend pour faire confiance aux en-têtes du protocole Proxy de Traefik.", "restarting": "Redémarrage...", "manual": "Manuel", "messageSupport": "Soutien aux messages", "supportNotAvailableTitle": "Support non disponible", "supportNotAvailableDescription": "L'assistance n'est pas disponible pour le moment. Vous pouvez envoyer un e-mail à support@pangolin.net.", "supportRequestSentTitle": "Demande de support envoyée", "supportRequestSentDescription": "Votre message a été envoyé avec succès.", "supportRequestFailedTitle": "Échec de l'envoi de la demande", "supportRequestFailedDescription": "Une erreur s'est produite lors de l'envoi de votre demande d'assistance.", "supportSubjectRequired": "Le sujet est requis", "supportSubjectMaxLength": "Le sujet doit être de 255 caractères ou moins", "supportMessageRequired": "Le message est requis", "supportReplyTo": "Répondre à", "supportSubject": "Sujet", "supportSubjectPlaceholder": "Entrez le sujet", "supportMessage": "Message", "supportMessagePlaceholder": "Entrez votre message", "supportSending": "Envoi...", "supportSend": "Envoyer", "supportMessageSent": "Message envoyé !", "supportWillContact": "Nous vous contacterons sous peu!", "selectLogRetention": "Sélectionner la durée de rétention des logs", "terms": "Conditions générales de vente", "privacy": "Confidentialité", "security": "Sécurité", "docs": "Documents", "deviceActivation": "Activation de l'appareil", "deviceCodeInvalidFormat": "Le code doit contenir 9 caractères (par exemple, A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Code invalide ou expiré", "deviceCodeVerifyFailed": "Impossible de vérifier le code de l'appareil", "deviceCodeValidating": "Validation du code de l'appareil...", "deviceCodeVerifying": "Vérification de l'autorisation de l'appareil...", "signedInAs": "Connecté en tant que", "deviceCodeEnterPrompt": "Entrez le code affiché sur l'appareil", "continue": "Continuer", "deviceUnknownLocation": "Lieu inconnu", "deviceAuthorizationRequested": "Cette autorisation a été demandée à {location} sur {date}. Assurez-vous que vous faites confiance à cet appareil car il aura accès au compte.", "deviceLabel": "Appareil : {deviceName}", "deviceWantsAccess": "veut accéder à votre compte", "deviceExistingAccess": "Accès existant:", "deviceFullAccess": "Accès complet à votre compte", "deviceOrganizationsAccess": "Accès à toutes les organisations auxquelles votre compte a accès", "deviceAuthorize": "Autoriser {applicationName}", "deviceConnected": "Appareil connecté !", "deviceAuthorizedMessage": "L'appareil est autorisé à accéder à votre compte. Veuillez retourner à l'application client.", "pangolinCloud": "Nuage de Pangolin", "viewDevices": "Voir les appareils", "viewDevicesDescription": "Gérer vos appareils connectés", "noDevices": "Aucun appareil trouvé", "dateCreated": "Date de création", "unnamedDevice": "Appareil sans nom", "deviceQuestionRemove": "Êtes-vous sûr de vouloir supprimer cet appareil ?", "deviceMessageRemove": "Cette action ne peut être annulée.", "deviceDeleteConfirm": "Supprimer l'appareil", "deleteDevice": "Supprimer l'appareil", "errorLoadingDevices": "Erreur lors du chargement des appareils", "failedToLoadDevices": "Impossible de charger les appareils", "deviceDeleted": "Appareil supprimé", "deviceDeletedDescription": "L'appareil a été supprimé avec succès.", "errorDeletingDevice": "Erreur lors de la suppression de l'appareil", "failedToDeleteDevice": "Échec de la suppression de l'appareil", "showColumns": "Afficher les colonnes", "hideColumns": "Cacher les colonnes", "columnVisibility": "Visibilité des colonnes", "toggleColumn": "Activer/désactiver la colonne {columnName}", "allColumns": "Toutes les colonnes", "defaultColumns": "Colonnes par défaut", "customizeView": "Personnaliser l'apparence", "viewOptions": "Voir les options", "selectAll": "Tout sélectionner", "selectNone": "Ne rien sélectionner", "selectedResources": "Ressources sélectionnées", "enableSelected": "Activer la sélection", "disableSelected": "Désactiver la sélection", "checkSelectedStatus": "Vérifier le statut de la sélection", "clients": "Clients", "accessClientSelect": "Sélectionnez les clients de machine", "resourceClientDescription": "Les clients qui peuvent accéder à cette ressource", "regenerate": "Régénérer", "credentials": "Identifiants", "savecredentials": "Enregistrer les identifiants", "regenerateCredentialsButton": "Régénérer les identifiants", "regenerateCredentials": "Régénérer les identifiants", "generatedcredentials": "Identifiants générés", "copyandsavethesecredentials": "Copier et enregistrer ces identifiants", "copyandsavethesecredentialsdescription": "Ces identifiants ne seront pas affichés à nouveaux une fois cette page fermée. Enregistrez-les maintenant.", "credentialsSaved": "Identifiants enregistrés", "credentialsSavedDescription": "Les identifiants ont été régénérés et enregistrés avec succès.", "credentialsSaveError": "Erreur lors de l'enregistrement des identifiants", "credentialsSaveErrorDescription": "Une erreur s'est produite lors de la régénération et l'enregistrement des identifiants.", "regenerateCredentialsWarning": "La régénération des identifiants invalidera les identifiants précédents et provoquera une déconnexion. Assurez-vous de mettre à jour toutes les configurations qui utilisent ces identifiants.", "confirm": "Confirmer", "regenerateCredentialsConfirmation": "Voulez-vous vraiment régénérer les identifiants ?", "endpoint": "Endpoint", "Id": "Id", "SecretKey": "Clé privée", "niceId": "Joli ID", "niceIdUpdated": "Joli ID mis à jour", "niceIdUpdatedSuccessfully": "Joli ID mis à jour avec succès", "niceIdUpdateError": "Erreur lors de la mise à jour du joli ID", "niceIdUpdateErrorDescription": "Erreur lors de la mise à jour du joli ID.", "niceIdCannotBeEmpty": "Merci de renseigner un joli ID", "enterIdentifier": "Entrez l'identifiant", "identifier": "Identifiant", "deviceLoginUseDifferentAccount": "Pas vous ? Utilisez un autre compte.", "deviceLoginDeviceRequestingAccessToAccount": "Un appareil demande l'accès à ce compte.", "loginSelectAuthenticationMethod": "Sélectionnez une méthode d'authentification pour continuer.", "noData": "Aucune donnée", "machineClients": "Clients Machines", "install": "Installer", "run": "Exécuter", "clientNameDescription": "Le nom d'affichage du client qui peut être modifié plus tard.", "clientAddress": "Adresse du client (Avancé)", "setupFailedToFetchSubnet": "Impossible de récupérer le sous-réseau par défaut", "setupSubnetAdvanced": "Sous-réseau (Avancé)", "setupSubnetDescription": "Le sous-réseau du réseau interne de cette organisation.", "setupUtilitySubnet": "Sous-réseau utilitaire (Avancé)", "setupUtilitySubnetDescription": "Le sous-réseau pour les adresses alias de cette organisation et le serveur DNS.", "siteRegenerateAndDisconnect": "Régénérer et déconnecter", "siteRegenerateAndDisconnectConfirmation": "Êtes-vous sûr de vouloir régénérer les identifiants et déconnecter ce site ?", "siteRegenerateAndDisconnectWarning": "Cela va régénérer les identifiants et déconnecter immédiatement le site. Le site devra être redémarré avec les nouveaux identifiants.", "siteRegenerateCredentialsConfirmation": "Êtes-vous sûr de vouloir régénérer les identifiants pour ce site ?", "siteRegenerateCredentialsWarning": "Cela va régénérer les identifiants. Le site restera connecté jusqu'à ce que vous le redémarriez manuellement et utilisez les nouveaux identifiants.", "clientRegenerateAndDisconnect": "Régénérer et déconnecter", "clientRegenerateAndDisconnectConfirmation": "Êtes-vous sûr de vouloir régénérer les identifiants et déconnecter ce client?", "clientRegenerateAndDisconnectWarning": "Cela va régénérer les identifiants et déconnecter immédiatement le client. Le client devra être redémarré avec les nouveaux identifiants.", "clientRegenerateCredentialsConfirmation": "Êtes-vous sûr de vouloir régénérer les identifiants pour ce client ?", "clientRegenerateCredentialsWarning": "Cela va régénérer les identifiants. Le client restera connecté jusqu'à ce que vous le redémarriez manuellement et utilisiez les nouveaux identifiants.", "remoteExitNodeRegenerateAndDisconnect": "Régénérer et déconnecter", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Êtes-vous sûr de vouloir régénérer les identifiants et déconnecter ce noeud de sortie distant ?", "remoteExitNodeRegenerateAndDisconnectWarning": "Cela va régénérer les identifiants et déconnecter immédiatement le noeud de sortie distant. Le noeud de sortie distant devra être redémarré avec les nouveaux identifiants.", "remoteExitNodeRegenerateCredentialsConfirmation": "Êtes-vous sûr de vouloir régénérer les informations d'identification pour ce noeud de sortie distant ?", "remoteExitNodeRegenerateCredentialsWarning": "Cela va régénérer les identifiants. Le noeud de sortie distant restera connecté jusqu'à ce que vous le redémarriez manuellement et utilisez les nouveaux identifiants.", "agent": "Agent", "personalUseOnly": "Pour usage personnel uniquement", "loginPageLicenseWatermark": "Cette instance est sous licence pour un usage personnel uniquement.", "instanceIsUnlicensed": "Cette instance n'est pas sous licence.", "portRestrictions": "Restrictions de port", "allPorts": "Tous", "custom": "Personnalisé", "allPortsAllowed": "Tous les ports autorisés", "allPortsBlocked": "Tous les ports bloqués", "tcpPortsDescription": "Indiquez les ports TCP autorisés pour cette ressource. Utilisez '*' pour tous les ports, laissez vide pour tout bloquer, ou entrez une liste de ports et de plages séparés par des virgules (par exemple, 80,443,8000-9000).", "udpPortsDescription": "Indiquez les ports UDP autorisés pour cette ressource. Utilisez '*' pour tous les ports, laissez vide pour tout bloquer, ou entrez une liste de ports et de plages séparés par des virgules (par exemple, 53,123,500-600).", "organizationLoginPageTitle": "Page de connexion de l'organisation", "organizationLoginPageDescription": "Personnalisez la page de connexion pour cette organisation", "resourceLoginPageTitle": "Page de connexion de la ressource", "resourceLoginPageDescription": "Personnalisez la page de connexion pour les ressources individuelles", "enterConfirmation": "Entrez la confirmation", "blueprintViewDetails": "Détails", "defaultIdentityProvider": "Fournisseur d'identité par défaut", "defaultIdentityProviderDescription": "Lorsqu'un fournisseur d'identité par défaut est sélectionné, l'utilisateur sera automatiquement redirigé vers le fournisseur pour authentification.", "editInternalResourceDialogNetworkSettings": "Paramètres réseau", "editInternalResourceDialogAccessPolicy": "Politique d'accès", "editInternalResourceDialogAddRoles": "Ajouter des rôles", "editInternalResourceDialogAddUsers": "Ajouter des utilisateurs", "editInternalResourceDialogAddClients": "Ajouter des clients", "editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationDescription": "Indiquez l'adresse de destination pour la ressource interne. Cela peut être un nom d'hôte, une adresse IP ou une plage CIDR selon le mode sélectionné. Définissez éventuellement un alias DNS interne pour une identification plus facile.", "editInternalResourceDialogPortRestrictionsDescription": "Restreindre l'accès à des ports TCP/UDP spécifiques ou autoriser/bloquer tous les ports.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Contrôle d'accès", "editInternalResourceDialogAccessControlDescription": "Contrôlez quels rôles, utilisateurs et clients de machine ont accès à cette ressource lorsqu'ils sont connectés. Les administrateurs ont toujours accès.", "editInternalResourceDialogPortRangeValidationError": "La plage de ports doit être \"*\" pour tous les ports, ou une liste de ports et de plages séparés par des virgules (par exemple, \"80,443,8000-9000\"). Les ports doivent être compris entre 1 et 65535.", "internalResourceAuthDaemonStrategy": "Emplacement du démon d'authentification SSH", "internalResourceAuthDaemonStrategyDescription": "Choisissez où le démon d'authentification SSH s'exécute : sur le site (Newt) ou sur un hôte distant.", "internalResourceAuthDaemonDescription": "Le démon d'authentification SSH gère la signature des clés SSH et l'authentification PAM pour cette ressource. Choisissez s'il fonctionne sur le site (Newt) ou sur un hôte distant séparé. Voir la documentation pour plus d'informations.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Choisir une stratégie", "internalResourceAuthDaemonStrategyLabel": "Localisation", "internalResourceAuthDaemonSite": "Sur le site", "internalResourceAuthDaemonSiteDescription": "Le démon Auth fonctionne sur le site (Newt).", "internalResourceAuthDaemonRemote": "Hôte distant", "internalResourceAuthDaemonRemoteDescription": "Le démon Auth fonctionne sur un hôte qui n'est pas le site.", "internalResourceAuthDaemonPort": "Port du démon (optionnel)", "orgAuthWhatsThis": "Où puis-je trouver mon identifiant d'organisation ?", "learnMore": "En savoir plus", "backToHome": "Retour à l'accueil", "needToSignInToOrg": "Besoin d'utiliser le fournisseur d'identité de votre organisation ?", "maintenanceMode": "Mode de maintenance", "maintenanceModeDescription": "Afficher une page de maintenance aux visiteurs", "maintenanceModeType": "Type de mode de maintenance", "showMaintenancePage": "Afficher une page de maintenance aux visiteurs", "enableMaintenanceMode": "Activer le mode de maintenance", "automatic": "Automatique", "automaticModeDescription": "Afficher la page de maintenance uniquement lorsque toutes les cibles backend sont en panne ou dégradées. Votre ressource continue à fonctionner normalement tant qu'au moins une cible est en bonne santé.", "forced": "Forcé", "forcedModeDescription": "Toujours afficher la page de maintenance indépendamment de l'état des backend. Utilisez ceci pour une maintenance planifiée lorsque vous souhaitez empêcher tout accès.", "warning:": "Attention :", "forcedeModeWarning": "Tout le trafic sera dirigé vers la page de maintenance. Vos ressources backend ne recevront aucune demande.", "pageTitle": "Titre de la page", "pageTitleDescription": "Le titre principal affiché sur la page de maintenance", "maintenancePageMessage": "Message de maintenance", "maintenancePageMessagePlaceholder": "Nous serons bientôt de retour ! Notre site est actuellement en maintenance planifiée.", "maintenancePageMessageDescription": "Message détaillé expliquant la maintenance", "maintenancePageTimeTitle": "Temps d'achèvement estimé (facultatif)", "maintenanceTime": "par exemple, 2 heures, le 1er nov. à 17:00", "maintenanceEstimatedTimeDescription": "Quand vous attendez que la maintenance soit terminée", "editDomain": "Modifier le domaine", "editDomainDescription": "Sélectionnez un domaine pour votre ressource", "maintenanceModeDisabledTooltip": "Cette fonctionnalité nécessite une licence valide pour être activée.", "maintenanceScreenTitle": "Service temporairement indisponible", "maintenanceScreenMessage": "Nous rencontrons actuellement des difficultés techniques. Veuillez vérifier ultérieurement.", "maintenanceScreenEstimatedCompletion": "Achèvement estimé :", "createInternalResourceDialogDestinationRequired": "La destination est requise", "available": "Disponible", "archived": "Archivé", "noArchivedDevices": "Aucun périphérique archivé trouvé", "deviceArchived": "Appareil archivé", "deviceArchivedDescription": "L'appareil a été archivé avec succès.", "errorArchivingDevice": "Erreur lors de l'archivage du périphérique", "failedToArchiveDevice": "Impossible d'archiver l'appareil", "deviceQuestionArchive": "Êtes-vous sûr de vouloir archiver cet appareil ?", "deviceMessageArchive": "Le périphérique sera archivé et retiré de la liste des périphériques actifs.", "deviceArchiveConfirm": "Dispositif d'archivage", "archiveDevice": "Dispositif d'archivage", "archive": "Archive", "deviceUnarchived": "Appareil désarchivé", "deviceUnarchivedDescription": "L'appareil a été désarchivé avec succès.", "errorUnarchivingDevice": "Erreur lors de la désarchivage du périphérique", "failedToUnarchiveDevice": "Échec de la désarchivage de l'appareil", "unarchive": "Désarchiver", "archiveClient": "Archiver le client", "archiveClientQuestion": "Êtes-vous sûr de vouloir archiver ce client?", "archiveClientMessage": "Le client sera archivé et retiré de votre liste de clients actifs.", "archiveClientConfirm": "Archiver le client", "blockClient": "Bloquer le client", "blockClientQuestion": "Êtes-vous sûr de vouloir bloquer ce client?", "blockClientMessage": "L'appareil sera forcé de se déconnecter si vous êtes actuellement connecté. Vous pourrez débloquer l'appareil plus tard.", "blockClientConfirm": "Bloquer le client", "active": "Actif", "usernameOrEmail": "Nom d'utilisateur ou email", "selectYourOrganization": "Sélectionnez votre organisation", "signInTo": "Se connecter à", "signInWithPassword": "Continuer avec le mot de passe", "noAuthMethodsAvailable": "Aucune méthode d'authentification disponible pour cette organisation.", "enterPassword": "Entrez votre mot de passe", "enterMfaCode": "Entrez le code de votre application d'authentification", "securityKeyRequired": "Veuillez utiliser votre clé de sécurité pour vous connecter.", "needToUseAnotherAccount": "Besoin d'un autre compte ?", "loginLegalDisclaimer": "En cliquant sur les boutons ci-dessous, vous reconnaissez avoir lu, compris et accepté les Conditions d'utilisation et la Politique de confidentialité.", "termsOfService": "Conditions d'utilisation", "privacyPolicy": "Politique de confidentialité", "userNotFoundWithUsername": "Aucun utilisateur trouvé avec ce nom d'utilisateur.", "verify": "Vérifier", "signIn": "Se connecter", "forgotPassword": "Mot de passe oublié ?", "orgSignInTip": "Si vous vous êtes déjà connecté, vous pouvez entrer votre nom d'utilisateur ou votre e-mail ci-dessus pour vous authentifier auprès du fournisseur d'identité de votre organisation. C'est plus facile !", "continueAnyway": "Continuer quand même", "dontShowAgain": "Ne plus afficher", "orgSignInNotice": "Le saviez-vous ?", "signupOrgNotice": "Vous essayez de vous connecter ?", "signupOrgTip": "Essayez-vous de vous connecter par l'intermédiaire du fournisseur d'identité de votre organisme?", "signupOrgLink": "Connectez-vous ou inscrivez-vous avec votre organisation à la place", "verifyEmailLogInWithDifferentAccount": "Utiliser un compte différent", "logIn": "Se connecter", "deviceInformation": "Informations sur l'appareil", "deviceInformationDescription": "Informations sur l'appareil et l'agent", "deviceSecurity": "Sécurité de l'appareil", "deviceSecurityDescription": "Informations sur la posture de sécurité de l'appareil", "platform": "Plateforme", "macosVersion": "Version macOS", "windowsVersion": "Version de Windows", "iosVersion": "Version iOS", "androidVersion": "Version d'Android", "osVersion": "Version du système d'exploitation", "kernelVersion": "Version du noyau", "deviceModel": "Modèle de l'appareil", "serialNumber": "Numéro de série", "hostname": "Hostname", "firstSeen": "Première vue", "lastSeen": "Dernière vue", "biometricsEnabled": "biométrique activée", "diskEncrypted": "Disque chiffré", "firewallEnabled": "Pare-feu activé", "autoUpdatesEnabled": "Mises à jour automatiques activées", "tpmAvailable": "TPM disponible", "windowsAntivirusEnabled": "Antivirus activé", "macosSipEnabled": "Protection contre l'intégrité du système (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Mode furtif du pare-feu", "linuxAppArmorEnabled": "Armure d'application", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "Afficher les informations et les paramètres de l'appareil", "devicePendingApprovalDescription": "Cet appareil est en attente d'approbation", "deviceBlockedDescription": "Cet appareil est actuellement bloqué. Il ne pourra se connecter à aucune ressource à moins d'être débloqué.", "unblockClient": "Débloquer le client", "unblockClientDescription": "L'appareil a été débloqué", "unarchiveClient": "Désarchiver le client", "unarchiveClientDescription": "L'appareil a été désarchivé", "block": "Bloquer", "unblock": "Débloquer", "deviceActions": "Actions de l'appareil", "deviceActionsDescription": "Gérer le statut et l'accès de l'appareil", "devicePendingApprovalBannerDescription": "Cet appareil est en attente d'approbation. Il ne sera pas en mesure de se connecter aux ressources jusqu'à ce qu'il soit approuvé.", "connected": "Connecté", "disconnected": "Déconnecté", "approvalsEmptyStateTitle": "Approbations de l'appareil non activées", "approvalsEmptyStateDescription": "Activer les autorisations de l'appareil pour les rôles qui nécessitent l'approbation de l'administrateur avant que les utilisateurs puissent connecter de nouveaux appareils.", "approvalsEmptyStateStep1Title": "Aller aux Rôles", "approvalsEmptyStateStep1Description": "Accédez aux paramètres de rôles de votre organisation pour configurer les autorisations de l'appareil.", "approvalsEmptyStateStep2Title": "Activer les autorisations de l'appareil", "approvalsEmptyStateStep2Description": "Modifier un rôle et activer l'option 'Exiger les autorisations de l'appareil'. Les utilisateurs avec ce rôle auront besoin de l'approbation de l'administrateur pour les nouveaux appareils.", "approvalsEmptyStatePreviewDescription": "Aperçu: Lorsque cette option est activée, les demandes de périphérique en attente apparaîtront ici pour vérification", "approvalsEmptyStateButtonText": "Gérer les rôles" } ================================================ FILE: messages/it-IT.json ================================================ { "setupCreate": "Creare l'organizzazione, il sito e le risorse", "headerAuthCompatibilityInfo": "Abilita questo per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.", "headerAuthCompatibility": "Compatibilità estesa", "setupNewOrg": "Nuova Organizzazione", "setupCreateOrg": "Crea Organizzazione", "setupCreateResources": "Crea Risorse", "setupOrgName": "Nome Dell'Organizzazione", "orgDisplayName": "Questo è il nome visualizzato dell'organizzazione.", "orgId": "Id Organizzazione", "setupIdentifierMessage": "Questo è l'identificatore univoco per l'organizzazione.", "setupErrorIdentifier": "L'ID dell'organizzazione è già utilizzato. Si prega di sceglierne uno diverso.", "componentsErrorNoMemberCreate": "Al momento non sei un membro di nessuna organizzazione. Crea un'organizzazione per iniziare.", "componentsErrorNoMember": "Attualmente non sei membro di nessuna organizzazione.", "welcome": "Benvenuti a Pangolin", "welcomeTo": "Benvenuto a", "componentsCreateOrg": "Crea un'organizzazione", "componentsMember": "Sei un membro di {count, plural, =0 {nessuna organizzazione} one {un'organizzazione} other {# organizzazioni}}.", "componentsInvalidKey": "Rilevata chiave di licenza non valida o scaduta. Segui i termini di licenza per continuare a utilizzare tutte le funzionalità.", "dismiss": "Ignora", "subscriptionViolationMessage": "Hai superato i tuoi limiti per il tuo piano attuale. Correggi il problema rimuovendo siti, utenti o altre risorse per rimanere all'interno del tuo piano.", "subscriptionViolationViewBilling": "Visualizza fatturazione", "componentsLicenseViolation": "Violazione della licenza: Questo server sta usando i siti {usedSites} che superano il suo limite concesso in licenza per i siti {maxSites} . Segui i termini di licenza per continuare a usare tutte le funzionalità.", "componentsSupporterMessage": "Grazie per aver supportato Pangolin come {tier}!", "inviteErrorNotValid": "Siamo spiacenti, ma sembra che l'invito che stai cercando di accedere non sia stato accettato o non sia più valido.", "inviteErrorUser": "Siamo spiacenti, ma sembra che l'invito che stai cercando di accedere non sia per questo utente.", "inviteLoginUser": "Assicurati di aver effettuato l'accesso come utente corretto.", "inviteErrorNoUser": "Siamo spiacenti, ma sembra che l'invito che stai cercando di accedere non sia per un utente che esiste.", "inviteCreateUser": "Si prega di creare un account prima.", "goHome": "Vai A Home", "inviteLogInOtherUser": "Accedi come utente diverso", "createAnAccount": "Crea un account", "inviteNotAccepted": "Invito Non Accettato", "authCreateAccount": "Crea un account per iniziare", "authNoAccount": "Non hai un account?", "email": "Email", "password": "Password", "confirmPassword": "Conferma Password", "createAccount": "Crea Account", "viewSettings": "Visualizza impostazioni", "delete": "Elimina", "name": "Nome", "online": "In linea", "offline": "Non in linea", "site": "Sito", "dataIn": "Dati In", "dataOut": "Dati Fuori", "connectionType": "Tipo Di Connessione", "tunnelType": "Tipo Di Tunnel", "local": "Locale", "edit": "Modifica", "siteConfirmDelete": "Conferma Eliminazione Sito", "siteDelete": "Elimina Sito", "siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli obiettivi associati al sito verranno rimossi.", "siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?", "siteManageSites": "Gestisci Siti", "siteDescription": "Creare e gestire siti per abilitare la connettività a reti private", "sitesBannerTitle": "Connetti Qualsiasi Rete", "sitesBannerDescription": "Un sito è una connessione a una rete remota che consente a Pangolin di fornire accesso alle risorse, pubbliche o private, agli utenti ovunque. Installa il connettore di rete del sito (Newt) ovunque tu possa eseguire un binario o un container per stabilire la connessione.", "sitesBannerButtonText": "Installa Sito", "approvalsBannerTitle": "Approva o nega l'accesso al dispositivo", "approvalsBannerDescription": "Controlla e approva o nega le richieste di accesso al dispositivo da parte degli utenti. Quando le approvazioni del dispositivo sono richieste, gli utenti devono ottenere l'approvazione dell'amministratore prima che i loro dispositivi possano connettersi alle risorse della vostra organizzazione.", "approvalsBannerButtonText": "Scopri di più", "siteCreate": "Crea Sito", "siteCreateDescription2": "Segui i passaggi qui sotto per creare e collegare un nuovo sito", "siteCreateDescription": "Crea un nuovo sito per iniziare a connettere le risorse", "close": "Chiudi", "siteErrorCreate": "Errore nella creazione del sito", "siteErrorCreateKeyPair": "Coppia di chiavi o valori predefiniti del sito non trovati", "siteErrorCreateDefaults": "Predefiniti del sito non trovati", "method": "Metodo", "siteMethodDescription": "Questo è il modo in cui esporrete le connessioni.", "siteLearnNewt": "Scopri come installare Newt sul tuo sistema", "siteSeeConfigOnce": "Potrai vedere la configurazione solo una volta.", "siteLoadWGConfig": "Caricamento configurazione WireGuard...", "siteDocker": "Espandi per i dettagli di distribuzione Docker", "toggle": "Attiva/disattiva", "dockerCompose": "Composizione Docker", "dockerRun": "Corsa Docker", "siteLearnLocal": "I siti locali non tunnel, saperne di più", "siteConfirmCopy": "Ho copiato la configurazione", "searchSitesProgress": "Cerca siti...", "siteAdd": "Aggiungi Sito", "siteInstallNewt": "Installa Newt", "siteInstallNewtDescription": "Esegui Newt sul tuo sistema", "WgConfiguration": "Configurazione WireGuard", "WgConfigurationDescription": "Utilizzare la seguente configurazione per connettersi alla rete", "operatingSystem": "Sistema Operativo", "commands": "Comandi", "recommended": "Consigliato", "siteNewtDescription": "Per la migliore esperienza utente, utilizzare Newt. Utilizza WireGuard sotto il cofano e ti permette di indirizzare le tue risorse private tramite il loro indirizzo LAN sulla tua rete privata dall'interno della dashboard Pangolin.", "siteRunsInDocker": "Esegue nel Docker", "siteRunsInShell": "Esegue in shell su macOS, Linux e Windows", "siteErrorDelete": "Errore nell'eliminare il sito", "siteErrorUpdate": "Impossibile aggiornare il sito", "siteErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento del sito.", "siteUpdated": "Sito aggiornato", "siteUpdatedDescription": "Il sito è stato aggiornato.", "siteGeneralDescription": "Configura le impostazioni generali per questo sito", "siteSettingDescription": "Configura le impostazioni del sito", "siteSetting": "Impostazioni {siteName}", "siteNewtTunnel": "Nuovo Sito (Consigliato)", "siteNewtTunnelDescription": "Modo più semplice per creare un entrypoint in qualsiasi rete. Nessuna configurazione aggiuntiva.", "siteWg": "WireGuard Base", "siteWgDescription": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta.", "siteWgDescriptionSaas": "Usa qualsiasi client WireGuard per stabilire un tunnel. Impostazione NAT manuale richiesta. FUNZIONA SOLO SU NODI AUTO-OSPITATI", "siteLocalDescription": "Solo risorse locali. Nessun tunneling.", "siteLocalDescriptionSaas": "Solo risorse locali. Nessun tunneling. Disponibile solo su nodi remoti.", "siteSeeAll": "Vedi Tutti I Siti", "siteTunnelDescription": "Determinare come si desidera connettersi al sito", "siteNewtCredentials": "Credenziali", "siteNewtCredentialsDescription": "Questo è come il sito si autenticerà con il server", "remoteNodeCredentialsDescription": "Questo è come il nodo remoto si autenticherà con il server", "siteCredentialsSave": "Salva le credenziali", "siteCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.", "siteInfo": "Informazioni Sito", "status": "Stato", "shareTitle": "Gestisci Collegamenti Di Condivisione", "shareDescription": "Crea link condivisibili per concedere accesso temporaneo o permanente alle risorse proxy", "shareSearch": "Cerca link condivisi...", "shareCreate": "Crea Link Di Condivisione", "shareErrorDelete": "Impossibile eliminare il link", "shareErrorDeleteMessage": "Si è verificato un errore durante l'eliminazione del link", "shareDeleted": "Link eliminato", "shareDeletedDescription": "Il link è stato eliminato", "shareTokenDescription": "Il token di accesso può essere passato in due modi: come parametro di interrogazione o nelle intestazioni della richiesta. Questi devono essere passati dal client su ogni richiesta di accesso autenticato.", "accessToken": "Token Di Accesso", "usageExamples": "Esempi Di Utilizzo", "tokenId": "ID del Token", "requestHeades": "Richiedi Intestazioni", "queryParameter": "Parametro Query", "importantNote": "Nota Importante", "shareImportantDescription": "Per motivi di sicurezza, si consiglia di utilizzare le intestazioni su parametri di query quando possibile, in quanto i parametri di query possono essere registrati in log server o cronologia browser.", "token": "Token", "shareTokenSecurety": "Mantenere sicuro il token di accesso. Non condividerlo in aree accessibili al pubblico o codice lato client.", "shareErrorFetchResource": "Recupero delle risorse non riuscito", "shareErrorFetchResourceDescription": "Si è verificato un errore durante il recupero delle risorse", "shareErrorCreate": "Impossibile creare il link di condivisione", "shareErrorCreateDescription": "Si è verificato un errore durante la creazione del link di condivisione", "shareCreateDescription": "Chiunque con questo link può accedere alla risorsa", "shareTitleOptional": "Titolo (facoltativo)", "expireIn": "Scadenza In", "neverExpire": "Mai scadere", "shareExpireDescription": "Il tempo di scadenza è per quanto tempo il link sarà utilizzabile e fornirà accesso alla risorsa. Dopo questo tempo, il link non funzionerà più e gli utenti che hanno utilizzato questo link perderanno l'accesso alla risorsa.", "shareSeeOnce": "Potrai vedere questo link solo una volta. Assicurati di copiarlo.", "shareAccessHint": "Chiunque abbia questo link può accedere alla risorsa. Condividilo con cura.", "shareTokenUsage": "Vedi Utilizzo Token Di Accesso", "createLink": "Crea Collegamento", "resourcesNotFound": "Nessuna risorsa trovata", "resourceSearch": "Cerca risorse", "openMenu": "Apri menu", "resource": "Risorsa", "title": "Titolo", "created": "Creato", "expires": "Scade", "never": "Mai", "shareErrorSelectResource": "Seleziona una risorsa", "proxyResourceTitle": "Gestisci Risorse Pubbliche", "proxyResourceDescription": "Creare e gestire risorse accessibili al pubblico tramite un browser web", "proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web", "proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili a chiunque su Internet tramite un browser web. A differenza delle risorse private, non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.", "clientResourceTitle": "Gestisci Risorse Private", "clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso", "privateResourcesBannerTitle": "Accesso Privato Zero-Trust", "privateResourcesBannerDescription": "Le risorse private utilizzano la sicurezza zero-trust, assicurandosi che gli utenti e le macchine possano accedere solo alle risorse a cui hai concesso esplicitamente l'accesso. Collega i dispositivi utente o i client macchina per accedere a queste risorse tramite una rete privata virtuale sicura.", "resourcesSearch": "Cerca risorse...", "resourceAdd": "Aggiungi Risorsa", "resourceErrorDelte": "Errore nell'eliminare la risorsa", "authentication": "Autenticazione", "protected": "Protetto", "notProtected": "Non Protetto", "resourceMessageRemove": "Una volta rimossa, la risorsa non sarà più accessibile. Tutti gli obiettivi associati alla risorsa saranno rimossi.", "resourceQuestionRemove": "Sei sicuro di voler rimuovere la risorsa dall'organizzazione?", "resourceHTTP": "Risorsa HTTPS", "resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.", "resourceRaw": "Risorsa Raw TCP/UDP", "resourceRawDescription": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta.", "resourceRawDescriptionCloud": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta. RICHIEDE L'USO DI UN NODO REMOTO.", "resourceCreate": "Crea Risorsa", "resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa", "resourceSeeAll": "Vedi Tutte Le Risorse", "resourceInfo": "Informazioni Risorsa", "resourceNameDescription": "Questo è il nome visualizzato per la risorsa.", "siteSelect": "Seleziona sito", "siteSearch": "Cerca sito", "siteNotFound": "Nessun sito trovato.", "selectCountry": "Seleziona paese", "searchCountries": "Cerca paesi...", "noCountryFound": "Nessun paese trovato.", "siteSelectionDescription": "Questo sito fornirà connettività all'obiettivo.", "resourceType": "Tipo Di Risorsa", "resourceTypeDescription": "Determinare come accedere alla risorsa", "resourceHTTPSSettings": "Impostazioni HTTPS", "resourceHTTPSSettingsDescription": "Configura come sarà possibile accedere alla risorsa su HTTPS", "domainType": "Tipo Di Dominio", "subdomain": "Sottodominio", "baseDomain": "Dominio Base", "subdomnainDescription": "Il sottodominio in cui la risorsa sarà accessibile.", "resourceRawSettings": "Impostazioni TCP/UDP", "resourceRawSettingsDescription": "Configura come accedere alla risorsa tramite TCP/UDP", "protocol": "Protocollo", "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", "resourceAddEntrypoints": "Traefik: Aggiungi Ingresso", "resourceExposePorts": "Gerbil: espone le porte in Docker componi", "resourceLearnRaw": "Scopri come configurare le risorse TCP/UDP", "resourceBack": "Torna alle risorse", "resourceGoTo": "Vai alla Risorsa", "resourceDelete": "Elimina Risorsa", "resourceDeleteConfirm": "Conferma Eliminazione Risorsa", "visibility": "Visibilità", "enabled": "Abilitato", "disabled": "Disabilitato", "general": "Generale", "generalSettings": "Impostazioni Generali", "proxy": "Proxy", "internal": "Interno", "rules": "Regole", "resourceSettingDescription": "Configura le impostazioni sulla risorsa", "resourceSetting": "Impostazioni {resourceName}", "alwaysAllow": "Autenticazione Bypass", "alwaysDeny": "Blocca Accesso", "passToAuth": "Passa all'autenticazione", "orgSettingsDescription": "Configura le impostazioni dell'organizzazione", "orgGeneralSettings": "Impostazioni Organizzazione", "orgGeneralSettingsDescription": "Gestisci i dettagli e la configurazione dell'organizzazione", "saveGeneralSettings": "Salva Impostazioni Generali", "saveSettings": "Salva Impostazioni", "orgDangerZone": "Zona Pericolosa", "orgDangerZoneDescription": "Una volta che si elimina questo org, non c'è ritorno. Si prega di essere certi.", "orgDelete": "Elimina Organizzazione", "orgDeleteConfirm": "Conferma Elimina Organizzazione", "orgMessageRemove": "Questa azione è irreversibile e cancellerà tutti i dati associati.", "orgMessageConfirm": "Per confermare, digita il nome dell'organizzazione qui sotto.", "orgQuestionRemove": "Sei sicuro di voler rimuovere l'organizzazione?", "orgUpdated": "Organizzazione aggiornata", "orgUpdatedDescription": "L'organizzazione è stata aggiornata.", "orgErrorUpdate": "Impossibile aggiornare l'organizzazione", "orgErrorUpdateMessage": "Si è verificato un errore nell'aggiornamento dell'organizzazione.", "orgErrorFetch": "Recupero delle organizzazioni non riuscito", "orgErrorFetchMessage": "Si è verificato un errore durante l'elenco delle organizzazioni", "orgErrorDelete": "Impossibile eliminare l'organizzazione", "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", "accessUsersDescription": "Invita e gestisci gli utenti con accesso a questa organizzazione", "accessUsersSearch": "Cerca utenti...", "accessUserCreate": "Crea Utente", "accessUserRemove": "Rimuovi Utente", "username": "Nome utente", "identityProvider": "Provider Di Identità", "role": "Ruolo", "nameRequired": "Il nome è obbligatorio", "accessRolesManage": "Gestisci Ruoli", "accessRolesDescription": "Creare e gestire ruoli per gli utenti nell'organizzazione", "accessRolesSearch": "Ricerca ruoli...", "accessRolesAdd": "Aggiungi Ruolo", "accessRoleDelete": "Elimina Ruolo", "accessApprovalsManage": "Gestisci Approvazioni", "accessApprovalsDescription": "Visualizza e gestisci le approvazioni in attesa per accedere a questa organizzazione", "description": "Descrizione", "inviteTitle": "Inviti Aperti", "inviteDescription": "Gestisci gli inviti per gli altri utenti a unirsi all'organizzazione", "inviteSearch": "Cerca inviti...", "minutes": "Minuti", "hours": "Ore", "days": "Giorni", "weeks": "Settimane", "months": "Mesi", "years": "Anni", "day": "{count, plural, one {# giorno} other {# giorni}}", "apiKeysTitle": "Informazioni Chiave API", "apiKeysConfirmCopy2": "Devi confermare di aver copiato la chiave API.", "apiKeysErrorCreate": "Errore nella creazione della chiave API", "apiKeysErrorSetPermission": "Errore nell'impostazione dei permessi", "apiKeysCreate": "Genera Chiave API", "apiKeysCreateDescription": "Genera una nuova chiave API per l'organizzazione", "apiKeysGeneralSettings": "Permessi", "apiKeysGeneralSettingsDescription": "Determina cosa può fare questa chiave API", "apiKeysList": "Nuova Chiave Api", "apiKeysSave": "Salva la chiave API", "apiKeysSaveDescription": "Potrai vederla solo una volta. Assicurati di copiarla in un luogo sicuro.", "apiKeysInfo": "La chiave API è:", "apiKeysConfirmCopy": "Ho copiato la chiave API", "generate": "Genera", "done": "Fatto", "apiKeysSeeAll": "Vedi Tutte Le Chiavi API", "apiKeysPermissionsErrorLoadingActions": "Errore nel caricamento delle azioni della chiave API", "apiKeysPermissionsErrorUpdate": "Errore nell'impostazione dei permessi", "apiKeysPermissionsUpdated": "Permessi aggiornati", "apiKeysPermissionsUpdatedDescription": "I permessi sono stati aggiornati.", "apiKeysPermissionsGeneralSettings": "Permessi", "apiKeysPermissionsGeneralSettingsDescription": "Determina cosa può fare questa chiave API", "apiKeysPermissionsSave": "Salva Permessi", "apiKeysPermissionsTitle": "Permessi", "apiKeys": "Chiavi API", "searchApiKeys": "Cerca chiavi API...", "apiKeysAdd": "Genera Chiave API", "apiKeysErrorDelete": "Errore nell'eliminazione della chiave API", "apiKeysErrorDeleteMessage": "Errore nell'eliminazione della chiave API", "apiKeysQuestionRemove": "Sei sicuro di voler rimuovere la chiave API dall'organizzazione?", "apiKeysMessageRemove": "Una volta rimossa, la chiave API non potrà più essere utilizzata.", "apiKeysDeleteConfirm": "Conferma Eliminazione Chiave API", "apiKeysDelete": "Elimina Chiave API", "apiKeysManage": "Gestisci Chiavi API", "apiKeysDescription": "Le chiavi API sono utilizzate per autenticarsi con l'API di integrazione", "apiKeysSettings": "Impostazioni {apiKeyName}", "userTitle": "Gestisci Tutti Gli Utenti", "userDescription": "Visualizza e gestisci tutti gli utenti del sistema", "userAbount": "Informazioni Sulla Gestione Utente", "userAbountDescription": "Questa tabella mostra tutti gli oggetti utente root nel sistema. Ogni utente può appartenere a più organizzazioni. La rimozione di un utente da un'organizzazione non elimina il suo oggetto utente root, che rimarrà nel sistema. Per rimuovere completamente un utente dal sistema, è necessario eliminare il loro oggetto utente root utilizzando l'azione di eliminazione in questa tabella.", "userServer": "Utenti Server", "userSearch": "Cerca utenti del server...", "userErrorDelete": "Errore nell'eliminare l'utente", "userDeleteConfirm": "Conferma Eliminazione Utente", "userDeleteServer": "Elimina utente dal server", "userMessageRemove": "L'utente verrà rimosso da tutte le organizzazioni ed essere completamente rimosso dal server.", "userQuestionRemove": "Sei sicuro di voler eliminare definitivamente l'utente dal server?", "licenseKey": "Chiave Di Licenza", "valid": "Valido", "numberOfSites": "Numero di siti", "licenseKeySearch": "Cerca chiavi di licenza...", "licenseKeyAdd": "Aggiungi Chiave Di Licenza", "type": "Tipo", "licenseKeyRequired": "La chiave di licenza è obbligatoria", "licenseTermsAgree": "Devi accettare i termini della licenza", "licenseErrorKeyLoad": "Impossibile caricare le chiavi di licenza", "licenseErrorKeyLoadDescription": "Si è verificato un errore durante il caricamento delle chiavi di licenza.", "licenseErrorKeyDelete": "Impossibile eliminare la chiave di licenza", "licenseErrorKeyDeleteDescription": "Si è verificato un errore durante l'eliminazione della chiave di licenza.", "licenseKeyDeleted": "Chiave di licenza eliminata", "licenseKeyDeletedDescription": "La chiave di licenza è stata eliminata.", "licenseErrorKeyActivate": "Attivazione della chiave di licenza non riuscita", "licenseErrorKeyActivateDescription": "Si è verificato un errore nell'attivazione della chiave di licenza.", "licenseAbout": "Informazioni Su Licenze", "communityEdition": "Edizione Community", "licenseAboutDescription": "Questo è per gli utenti aziendali e aziendali che utilizzano Pangolin in un ambiente commerciale. Se stai usando Pangolin per uso personale, puoi ignorare questa sezione.", "licenseKeyActivated": "Chiave di licenza attivata", "licenseKeyActivatedDescription": "La chiave di licenza è stata attivata correttamente.", "licenseErrorKeyRecheck": "Impossibile ricontrollare le chiavi di licenza", "licenseErrorKeyRecheckDescription": "Si è verificato un errore nel ricontrollare le chiavi di licenza.", "licenseErrorKeyRechecked": "Chiavi di licenza ricontrollate", "licenseErrorKeyRecheckedDescription": "Tutte le chiavi di licenza sono state ricontrollate", "licenseActivateKey": "Attiva Chiave Di Licenza", "licenseActivateKeyDescription": "Inserisci una chiave di licenza per attivarla.", "licenseActivate": "Attiva Licenza", "licenseAgreement": "Selezionando questa casella, confermi di aver letto e accettato i termini di licenza corrispondenti al livello associato alla chiave di licenza.", "fossorialLicense": "Visualizza I Termini Di Licenza Commerciale Fossorial E Abbonamento", "licenseMessageRemove": "Questo rimuoverà la chiave di licenza e tutti i permessi associati da essa concessi.", "licenseMessageConfirm": "Per confermare, digitare la chiave di licenza qui sotto.", "licenseQuestionRemove": "Sei sicuro di voler eliminare la chiave di licenza?", "licenseKeyDelete": "Elimina Chiave Di Licenza", "licenseKeyDeleteConfirm": "Conferma Elimina Chiave Di Licenza", "licenseTitle": "Gestisci Stato Licenza", "licenseTitleDescription": "Visualizza e gestisci le chiavi di licenza nel sistema", "licenseHost": "Licenza Host", "licenseHostDescription": "Gestisci la chiave di licenza principale per l'host.", "licensedNot": "Non Licenziato", "hostId": "ID Host", "licenseReckeckAll": "Ricontrolla Tutte Le Tasti", "licenseSiteUsage": "Utilizzo Siti", "licenseSiteUsageDecsription": "Visualizza il numero di siti che utilizzano questa licenza.", "licenseNoSiteLimit": "Non c'è alcun limite al numero di siti che utilizzano un host senza licenza.", "licensePurchase": "Acquista Licenza", "licensePurchaseSites": "Acquista Siti Aggiuntivi", "licenseSitesUsedMax": "{usedSites} di {maxSites} siti utilizzati", "licenseSitesUsed": "{count, plural, =0 {# siti} one {# sito} other {# siti}} nel sistema.", "licensePurchaseDescription": "Scegli quanti siti vuoi {selectedMode, select, license {acquista una licenza. Puoi sempre aggiungere altri siti più tardi.} other {aggiungi alla tua licenza esistente.}}", "licenseFee": "Costo della licenza", "licensePriceSite": "Prezzo per sito", "total": "Totale", "licenseContinuePayment": "Continua al pagamento", "pricingPage": "pagina prezzi", "pricingPortal": "Vedi Il Portale Di Acquisto", "licensePricingPage": "Per i prezzi e gli sconti più aggiornati, visita il ", "invite": "Inviti", "inviteRegenerate": "Rigenera Invito", "inviteRegenerateDescription": "Revoca l'invito precedente e creane uno nuovo", "inviteRemove": "Rimuovi Invito", "inviteRemoveError": "Impossibile rimuovere l'invito", "inviteRemoveErrorDescription": "Si è verificato un errore durante la rimozione dell'invito.", "inviteRemoved": "Invito rimosso", "inviteRemovedDescription": "L'invito per {email} è stato rimosso.", "inviteQuestionRemove": "Sei sicuro di voler rimuovere l'invito?", "inviteMessageRemove": "Una volta rimosso, questo invito non sarà più valido. Puoi sempre reinvitare l'utente in seguito.", "inviteMessageConfirm": "Per confermare, digita l'indirizzo email dell'invito qui sotto.", "inviteQuestionRegenerate": "Sei sicuro di voler rigenerare l'invito {email}? Questo revocherà l'invito precedente.", "inviteRemoveConfirm": "Conferma Rimozione Invito", "inviteRegenerated": "Invito Rigenerato", "inviteSent": "Un nuovo invito è stato inviato a {email}.", "inviteSentEmail": "Invia notifica email all'utente", "inviteGenerate": "Un nuovo invito è stato generato per {email}.", "inviteDuplicateError": "Invito Duplicato", "inviteDuplicateErrorDescription": "Esiste già un invito per questo utente.", "inviteRateLimitError": "Limite di Frequenza Superato", "inviteRateLimitErrorDescription": "Hai superato il limite di 3 rigenerazioni per ora. Riprova più tardi.", "inviteRegenerateError": "Impossibile Rigenerare l'Invito", "inviteRegenerateErrorDescription": "Si è verificato un errore durante la rigenerazione dell'invito.", "inviteValidityPeriod": "Periodo di Validità", "inviteValidityPeriodSelect": "Seleziona periodo di validità", "inviteRegenerateMessage": "L'invito è stato rigenerato. L'utente deve accedere al link qui sotto per accettare l'invito.", "inviteRegenerateButton": "Rigenera", "expiresAt": "Scade Il", "accessRoleUnknown": "Ruolo Sconosciuto", "placeholder": "Segnaposto", "userErrorOrgRemove": "Impossibile rimuovere l'utente", "userErrorOrgRemoveDescription": "Si è verificato un errore durante la rimozione dell'utente.", "userOrgRemoved": "Utente rimosso", "userOrgRemovedDescription": "L'utente {email} è stato rimosso dall'organizzazione.", "userQuestionOrgRemove": "Sei sicuro di voler rimuovere questo utente dall'organizzazione?", "userMessageOrgRemove": "Una volta rimosso, questo utente non avrà più accesso all'organizzazione. Puoi sempre reinvitarlo in seguito, ma dovrà accettare nuovamente l'invito.", "userRemoveOrgConfirm": "Conferma Rimozione Utente", "userRemoveOrg": "Rimuovi Utente dall'Organizzazione", "users": "Utenti", "accessRoleMember": "Membro", "accessRoleOwner": "Proprietario", "userConfirmed": "Confermato", "idpNameInternal": "Interno", "emailInvalid": "Indirizzo email non valido", "inviteValidityDuration": "Seleziona una durata", "accessRoleSelectPlease": "Seleziona un ruolo", "usernameRequired": "Username richiesto", "idpSelectPlease": "Seleziona un provider di identità", "idpGenericOidc": "Provider OAuth2/OIDC generico.", "accessRoleErrorFetch": "Impossibile recuperare i ruoli", "accessRoleErrorFetchDescription": "Si è verificato un errore durante il recupero dei ruoli", "idpErrorFetch": "Impossibile recuperare i provider di identità", "idpErrorFetchDescription": "Si è verificato un errore durante il recupero dei provider di identità", "userErrorExists": "Utente Già Esistente", "userErrorExistsDescription": "Questo utente è già membro dell'organizzazione.", "inviteError": "Impossibile invitare l'utente", "inviteErrorDescription": "Si è verificato un errore durante l'invito dell'utente", "userInvited": "Utente invitato", "userInvitedDescription": "L'utente è stato invitato con successo.", "userErrorCreate": "Impossibile creare l'utente", "userErrorCreateDescription": "Si è verificato un errore durante la creazione dell'utente", "userCreated": "Utente creato", "userCreatedDescription": "L'utente è stato creato con successo.", "userTypeInternal": "Utente Interno", "userTypeInternalDescription": "Invita un utente a unirsi direttamente all'organizzazione.", "userTypeExternal": "Utente Esterno", "userTypeExternalDescription": "Crea un utente con un provider di identità esterno.", "accessUserCreateDescription": "Segui i passaggi seguenti per creare un nuovo utente", "userSeeAll": "Vedi Tutti gli Utenti", "userTypeTitle": "Tipo di Utente", "userTypeDescription": "Determina come vuoi creare l'utente", "userSettings": "Informazioni Utente", "userSettingsDescription": "Inserisci i dettagli per il nuovo utente", "inviteEmailSent": "Invia email di invito all'utente", "inviteValid": "Valido Per", "selectDuration": "Seleziona durata", "selectResource": "Seleziona Risorsa", "filterByResource": "Filtra Per Risorsa", "selectApprovalState": "Seleziona Stato Di Approvazione", "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", "deniedApproval": "Omologazione Negata", "all": "Tutti", "deny": "Nega", "viewDetails": "Visualizza Dettagli", "requestingNewDeviceApproval": "ha richiesto un nuovo dispositivo", "resetFilters": "Ripristina Filtri", "totalBlocked": "Richieste Bloccate Da Pangolino", "totalRequests": "Totale Richieste", "requestsByCountry": "Richieste Per Paese", "requestsByDay": "Richieste Per Giorno", "blocked": "Bloccato", "allowed": "Consentito", "topCountries": "Paesi Principali", "accessRoleSelect": "Seleziona ruolo", "inviteEmailSentDescription": "È stata inviata un'email all'utente con il link di accesso qui sotto. Devono accedere al link per accettare l'invito.", "inviteSentDescription": "L'utente è stato invitato. Deve accedere al link qui sotto per accettare l'invito.", "inviteExpiresIn": "L'invito scadrà tra {days, plural, one {# giorno} other {# giorni}}.", "idpTitle": "Informazioni Generali", "idpSelect": "Seleziona il provider di identità per l'utente esterno", "idpNotConfigured": "Nessun provider di identità configurato. Configura un provider di identità prima di creare utenti esterni.", "usernameUniq": "Questo deve corrispondere all'username univoco esistente nel provider di identità selezionato.", "emailOptional": "Email (Opzionale)", "nameOptional": "Nome (Opzionale)", "accessControls": "Controlli di Accesso", "userDescription2": "Gestisci le impostazioni di questo utente", "accessRoleErrorAdd": "Impossibile aggiungere l'utente al ruolo", "accessRoleErrorAddDescription": "Si è verificato un errore durante l'aggiunta dell'utente al ruolo.", "userSaved": "Utente salvato", "userSavedDescription": "L'utente è stato aggiornato.", "autoProvisioned": "Auto Provisioned", "autoProvisionedDescription": "Permetti a questo utente di essere gestito automaticamente dal provider di identità", "accessControlsDescription": "Gestisci cosa questo utente può accedere e fare nell'organizzazione", "accessControlsSubmit": "Salva Controlli di Accesso", "roles": "Ruoli", "accessUsersRoles": "Gestisci Utenti e Ruoli", "accessUsersRolesDescription": "Invita gli utenti e aggiungili ai ruoli per gestire l'accesso all'organizzazione", "key": "Chiave", "createdAt": "Creato Il", "proxyErrorInvalidHeader": "Valore dell'intestazione Host personalizzata non valido. Usa il formato nome dominio o salva vuoto per rimuovere l'intestazione Host personalizzata.", "proxyErrorTls": "Nome Server TLS non valido. Usa il formato nome dominio o salva vuoto per rimuovere il Nome Server TLS.", "proxyEnableSSL": "Abilita SSL", "proxyEnableSSLDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure agli obiettivi.", "target": "Target", "configureTarget": "Configura Obiettivi", "targetErrorFetch": "Impossibile recuperare i target", "targetErrorFetchDescription": "Si è verificato un errore durante il recupero dei target", "siteErrorFetch": "Impossibile recuperare la risorsa", "siteErrorFetchDescription": "Si è verificato un errore durante il recupero della risorsa", "targetErrorDuplicate": "Target duplicato", "targetErrorDuplicateDescription": "Esiste già un target con queste impostazioni", "targetWireGuardErrorInvalidIp": "IP target non valido", "targetWireGuardErrorInvalidIpDescription": "L'IP target deve essere all'interno della subnet del sito", "targetsUpdated": "Target aggiornati", "targetsUpdatedDescription": "Target e impostazioni aggiornati con successo", "targetsErrorUpdate": "Impossibile aggiornare i target", "targetsErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento dei target", "targetTlsUpdate": "Impostazioni TLS aggiornate", "targetTlsUpdateDescription": "Le impostazioni TLS sono state aggiornate correttamente", "targetErrorTlsUpdate": "Impossibile aggiornare le impostazioni TLS", "targetErrorTlsUpdateDescription": "Si è verificato un errore durante l'aggiornamento delle impostazioni TLS", "proxyUpdated": "Impostazioni proxy aggiornate", "proxyUpdatedDescription": "Le impostazioni del proxy sono state aggiornate con successo", "proxyErrorUpdate": "Impossibile aggiornare le impostazioni proxy", "proxyErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento delle impostazioni proxy", "targetAddr": "Host", "targetPort": "Porta", "targetProtocol": "Protocollo", "targetTlsSettings": "Configurazione Connessione Sicura", "targetTlsSettingsDescription": "Configura le impostazioni SSL/TLS per la risorsa", "targetTlsSettingsAdvanced": "Impostazioni TLS Avanzate", "targetTlsSni": "Nome Server Tls", "targetTlsSniDescription": "Il Nome Server TLS da usare per SNI. Lascia vuoto per usare quello predefinito.", "targetTlsSubmit": "Salva Impostazioni", "targets": "Configurazione Target", "targetsDescription": "Impostare obiettivi per instradare il traffico verso i servizi di backend", "targetStickySessions": "Abilita Sessioni Persistenti", "targetStickySessionsDescription": "Mantieni le connessioni sullo stesso target backend per l'intera sessione.", "methodSelect": "Seleziona metodo", "targetSubmit": "Aggiungi Target", "targetNoOne": "Questa risorsa non ha destinazioni. Aggiungi un obiettivo per configurare dove inviare richieste al backend.", "targetNoOneDescription": "L'aggiunta di più di un target abiliterà il bilanciamento del carico.", "targetsSubmit": "Salva Target", "addTarget": "Aggiungi Target", "targetErrorInvalidIp": "Indirizzo IP non valido", "targetErrorInvalidIpDescription": "Inserisci un indirizzo IP o un hostname valido", "targetErrorInvalidPort": "Porta non valida", "targetErrorInvalidPortDescription": "Inserisci un numero di porta valido", "targetErrorNoSite": "Nessun sito selezionato", "targetErrorNoSiteDescription": "Si prega di selezionare un sito per l'obiettivo", "targetCreated": "Destinazione creata", "targetCreatedDescription": "L'obiettivo è stato creato con successo", "targetErrorCreate": "Impossibile creare l'obiettivo", "targetErrorCreateDescription": "Si è verificato un errore durante la creazione del target", "tlsServerName": "Nome Server Tls", "tlsServerNameDescription": "Il nome del server TLS da usare per SNI", "save": "Salva", "proxyAdditional": "Impostazioni Proxy Aggiuntive", "proxyAdditionalDescription": "Configura come la risorsa gestisce le impostazioni del proxy", "proxyCustomHeader": "Intestazione Host Personalizzata", "proxyCustomHeaderDescription": "L'intestazione host da impostare durante il proxy delle richieste. Lascia vuoto per usare quella predefinita.", "proxyAdditionalSubmit": "Salva Impostazioni Proxy", "subnetMaskErrorInvalid": "Maschera di sottorete non valida. Deve essere tra 0 e 32.", "ipAddressErrorInvalidFormat": "Formato indirizzo IP non valido", "ipAddressErrorInvalidOctet": "Ottetto indirizzo IP non valido", "path": "Percorso", "matchPath": "Corrispondenza Tracciato", "ipAddressRange": "Intervallo IP", "rulesErrorFetch": "Impossibile recuperare le regole", "rulesErrorFetchDescription": "Si è verificato un errore durante il recupero delle regole", "rulesErrorDuplicate": "Regola duplicata", "rulesErrorDuplicateDescription": "Esiste già una regola con queste impostazioni", "rulesErrorInvalidIpAddressRange": "CIDR non valido", "rulesErrorInvalidIpAddressRangeDescription": "Inserisci un valore CIDR valido", "rulesErrorInvalidUrl": "Percorso URL non valido", "rulesErrorInvalidUrlDescription": "Inserisci un valore di percorso URL valido", "rulesErrorInvalidIpAddress": "IP non valido", "rulesErrorInvalidIpAddressDescription": "Inserisci un indirizzo IP valido", "rulesErrorUpdate": "Impossibile aggiornare le regole", "rulesErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento delle regole", "rulesUpdated": "Abilita Regole", "rulesUpdatedDescription": "La valutazione delle regole è stata aggiornata", "rulesMatchIpAddressRangeDescription": "Inserisci un indirizzo in formato CIDR (es. 103.21.244.0/22)", "rulesMatchIpAddress": "Inserisci un indirizzo IP (es. 103.21.244.12)", "rulesMatchUrl": "Inserisci un percorso URL o pattern (es. /api/v1/todos o /api/v1/*)", "rulesErrorInvalidPriority": "Priorità Non Valida", "rulesErrorInvalidPriorityDescription": "Inserisci una priorità valida", "rulesErrorDuplicatePriority": "Priorità Duplicate", "rulesErrorDuplicatePriorityDescription": "Inserisci priorità uniche", "ruleUpdated": "Regole aggiornate", "ruleUpdatedDescription": "Regole aggiornate con successo", "ruleErrorUpdate": "Operazione fallita", "ruleErrorUpdateDescription": "Si è verificato un errore durante il salvataggio", "rulesPriority": "Priorità", "rulesAction": "Azione", "rulesMatchType": "Tipo di Corrispondenza", "value": "Valore", "rulesAbout": "Informazioni sulle Regole", "rulesAboutDescription": "Le regole consentono di controllare l'accesso alla risorsa in base a una serie di criteri. È possibile creare regole per consentire o negare l'accesso in base all'indirizzo IP o al percorso URL.", "rulesActions": "Azioni", "rulesActionAlwaysAllow": "Consenti Sempre: Ignora tutti i metodi di autenticazione", "rulesActionAlwaysDeny": "Nega Sempre: Blocca tutte le richieste; nessuna autenticazione può essere tentata", "rulesActionPassToAuth": "Passa all'autenticazione: Consenti di tentare i metodi di autenticazione", "rulesMatchCriteria": "Criteri di Corrispondenza", "rulesMatchCriteriaIpAddress": "Corrisponde a un indirizzo IP specifico", "rulesMatchCriteriaIpAddressRange": "Corrisponde a un intervallo di indirizzi IP in notazione CIDR", "rulesMatchCriteriaUrl": "Corrisponde a un percorso URL o pattern", "rulesEnable": "Abilita Regole", "rulesEnableDescription": "Abilita o disabilita la valutazione delle regole per questa risorsa", "rulesResource": "Configurazione Regole Risorsa", "rulesResourceDescription": "Configura le regole per controllare l'accesso alla risorsa", "ruleSubmit": "Aggiungi Regola", "rulesNoOne": "Nessuna regola. Aggiungi una regola usando il modulo.", "rulesOrder": "Le regole sono valutate per priorità in ordine crescente.", "rulesSubmit": "Salva Regole", "resourceErrorCreate": "Errore nella creazione della risorsa", "resourceErrorCreateDescription": "Si è verificato un errore durante la creazione della risorsa", "resourceErrorCreateMessage": "Errore nella creazione della risorsa:", "resourceErrorCreateMessageDescription": "Si è verificato un errore imprevisto", "sitesErrorFetch": "Errore nel recupero dei siti", "sitesErrorFetchDescription": "Si è verificato un errore durante il recupero dei siti", "domainsErrorFetch": "Errore nel recupero dei domini", "domainsErrorFetchDescription": "Si è verificato un errore durante il recupero dei domini", "none": "Nessuno", "unknown": "Sconosciuto", "resources": "Risorse", "resourcesDescription": "Le risorse sono proxy per applicazioni in esecuzione sulla rete privata. Crea una risorsa per qualsiasi servizio HTTP/HTTPS o TCP/UDP grezzo sulla tua rete privata. Ogni risorsa deve essere collegata a un sito per abilitare una connettività privata e sicura attraverso un tunnel WireGuard crittografato.", "resourcesWireGuardConnect": "Connettività sicura con crittografia WireGuard", "resourcesMultipleAuthenticationMethods": "Configura molteplici metodi di autenticazione", "resourcesUsersRolesAccess": "Controllo accessi basato su utenti e ruoli", "resourcesErrorUpdate": "Impossibile attivare/disattivare la risorsa", "resourcesErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento della risorsa", "access": "Accesso", "accessControl": "Controllo Accessi", "shareLink": "Link di Condivisione {resource}", "resourceSelect": "Seleziona risorsa", "shareLinks": "Link di Condivisione", "share": "Link Condivisibili", "shareDescription2": "Crea link condivisibili alle risorse. I link forniscono un accesso temporaneo o illimitato alla tua risorsa. È possibile configurare la durata di scadenza del collegamento quando ne viene creato uno.", "shareEasyCreate": "Facile da creare e condividere", "shareConfigurableExpirationDuration": "Durata di scadenza configurabile", "shareSecureAndRevocable": "Sicuro e revocabile", "nameMin": "Il nome deve essere di almeno {len} caratteri.", "nameMax": "Il nome non deve superare i {len} caratteri.", "sitesConfirmCopy": "Conferma di aver copiato la configurazione.", "unknownCommand": "Comando sconosciuto", "newtErrorFetchReleases": "Impossibile recuperare le informazioni sulla versione: {err}", "newtErrorFetchLatest": "Errore nel recupero dell'ultima versione: {err}", "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Segreto", "architecture": "Architettura", "sites": "Siti", "siteWgAnyClients": "Usa qualsiasi client WireGuard per connetterti. Dovrai indirizzare le risorse interne utilizzando l'IP del peer.", "siteWgCompatibleAllClients": "Compatibile con tutti i client WireGuard", "siteWgManualConfigurationRequired": "Configurazione manuale richiesta", "userErrorNotAdminOrOwner": "L'utente non è un amministratore o proprietario", "pangolinSettings": "Impostazioni - Pangolin", "accessRoleYour": "Il tuo ruolo:", "accessRoleSelect2": "Seleziona ruoli", "accessUserSelect": "Seleziona utenti", "otpEmailEnter": "Inserisci un'email", "otpEmailEnterDescription": "Premi invio per aggiungere un'email dopo averla digitata nel campo di input.", "otpEmailErrorInvalid": "Indirizzo email non valido. Il carattere jolly (*) deve essere l'intera parte locale.", "otpEmailSmtpRequired": "SMTP Richiesto", "otpEmailSmtpRequiredDescription": "SMTP deve essere abilitato sul server per utilizzare l'autenticazione con password monouso.", "otpEmailTitle": "Password Monouso", "otpEmailTitleDescription": "Richiedi autenticazione basata su email per l'accesso alle risorse", "otpEmailWhitelist": "Lista Autorizzazioni Email", "otpEmailWhitelistList": "Email Autorizzate", "otpEmailWhitelistListDescription": "Solo gli utenti con questi indirizzi email potranno accedere a questa risorsa. Verrà richiesto loro di inserire una password monouso inviata alla loro email. I caratteri jolly (*@example.com) possono essere utilizzati per consentire qualsiasi indirizzo email da un dominio.", "otpEmailWhitelistSave": "Salva Lista Autorizzazioni", "passwordAdd": "Aggiungi Password", "passwordRemove": "Rimuovi Password", "pincodeAdd": "Aggiungi Codice PIN", "pincodeRemove": "Rimuovi Codice PIN", "resourceAuthMethods": "Metodi di Autenticazione", "resourceAuthMethodsDescriptions": "Consenti l'accesso alla risorsa tramite metodi di autenticazione aggiuntivi", "resourceAuthSettingsSave": "Salvato con successo", "resourceAuthSettingsSaveDescription": "Le impostazioni di autenticazione sono state salvate", "resourceErrorAuthFetch": "Impossibile recuperare i dati", "resourceErrorAuthFetchDescription": "Si è verificato un errore durante il recupero dei dati", "resourceErrorPasswordRemove": "Errore nella rimozione della password della risorsa", "resourceErrorPasswordRemoveDescription": "Si è verificato un errore durante la rimozione della password della risorsa", "resourceErrorPasswordSetup": "Errore nell'impostazione della password della risorsa", "resourceErrorPasswordSetupDescription": "Si è verificato un errore durante l'impostazione della password della risorsa", "resourceErrorPincodeRemove": "Errore nella rimozione del codice PIN della risorsa", "resourceErrorPincodeRemoveDescription": "Si è verificato un errore durante la rimozione del codice PIN della risorsa", "resourceErrorPincodeSetup": "Errore nell'impostazione del codice PIN della risorsa", "resourceErrorPincodeSetupDescription": "Si è verificato un errore durante l'impostazione del codice PIN della risorsa", "resourceErrorUsersRolesSave": "Impossibile impostare i ruoli", "resourceErrorUsersRolesSaveDescription": "Si è verificato un errore durante l'impostazione dei ruoli", "resourceErrorWhitelistSave": "Impossibile salvare la lista autorizzazioni", "resourceErrorWhitelistSaveDescription": "Si è verificato un errore durante il salvataggio della lista autorizzazioni", "resourcePasswordSubmit": "Abilita Protezione Password", "resourcePasswordProtection": "Protezione Password {status}", "resourcePasswordRemove": "Password della risorsa rimossa", "resourcePasswordRemoveDescription": "La password della risorsa è stata rimossa con successo", "resourcePasswordSetup": "Password della risorsa impostata", "resourcePasswordSetupDescription": "La password della risorsa è stata impostata con successo", "resourcePasswordSetupTitle": "Imposta Password", "resourcePasswordSetupTitleDescription": "Imposta una password per proteggere questa risorsa", "resourcePincode": "Codice PIN", "resourcePincodeSubmit": "Abilita Protezione Codice PIN", "resourcePincodeProtection": "Protezione Codice PIN {status}", "resourcePincodeRemove": "Codice PIN della risorsa rimosso", "resourcePincodeRemoveDescription": "Il codice PIN della risorsa è stato rimosso con successo", "resourcePincodeSetup": "Codice PIN della risorsa impostato", "resourcePincodeSetupDescription": "Il codice PIN della risorsa è stato impostato con successo", "resourcePincodeSetupTitle": "Imposta Codice PIN", "resourcePincodeSetupTitleDescription": "Imposta un codice PIN per proteggere questa risorsa", "resourceRoleDescription": "Gli amministratori possono sempre accedere a questa risorsa.", "resourceUsersRoles": "Controlli di Accesso", "resourceUsersRolesDescription": "Configura quali utenti e ruoli possono visitare questa risorsa", "resourceUsersRolesSubmit": "Salva Controlli di Accesso", "resourceWhitelistSave": "Salvato con successo", "resourceWhitelistSaveDescription": "Le impostazioni della lista autorizzazioni sono state salvate", "ssoUse": "Usa SSO della Piattaforma", "ssoUseDescription": "Gli utenti esistenti dovranno accedere solo una volta per tutte le risorse che hanno questa opzione abilitata.", "proxyErrorInvalidPort": "Numero porta non valido", "subdomainErrorInvalid": "Sottodominio non valido", "domainErrorFetch": "Errore nel recupero dei domini", "domainErrorFetchDescription": "Si è verificato un errore durante il recupero dei domini", "resourceErrorUpdate": "Impossibile aggiornare la risorsa", "resourceErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento della risorsa", "resourceUpdated": "Risorsa aggiornata", "resourceUpdatedDescription": "La risorsa è stata aggiornata con successo", "resourceErrorTransfer": "Impossibile trasferire la risorsa", "resourceErrorTransferDescription": "Si è verificato un errore durante il trasferimento della risorsa", "resourceTransferred": "Risorsa trasferita", "resourceTransferredDescription": "La risorsa è stata trasferita con successo", "resourceErrorToggle": "Impossibile alternare la risorsa", "resourceErrorToggleDescription": "Si è verificato un errore durante l'aggiornamento della risorsa", "resourceVisibilityTitle": "Visibilità", "resourceVisibilityTitleDescription": "Abilita o disabilita completamente la visibilità della risorsa", "resourceGeneral": "Impostazioni Generali", "resourceGeneralDescription": "Configura le impostazioni generali per questa risorsa", "resourceEnable": "Abilita Risorsa", "resourceTransfer": "Trasferisci Risorsa", "resourceTransferDescription": "Trasferisci questa risorsa a un sito diverso", "resourceTransferSubmit": "Trasferisci Risorsa", "siteDestination": "Sito Di Destinazione", "searchSites": "Cerca siti", "countries": "Paesi", "accessRoleCreate": "Crea Ruolo", "accessRoleCreateDescription": "Crea un nuovo ruolo per raggruppare gli utenti e gestire i loro permessi.", "accessRoleEdit": "Modifica Ruolo", "accessRoleEditDescription": "Modifica informazioni sul ruolo.", "accessRoleCreateSubmit": "Crea Ruolo", "accessRoleCreated": "Ruolo creato", "accessRoleCreatedDescription": "Il ruolo è stato creato con successo.", "accessRoleErrorCreate": "Impossibile creare il ruolo", "accessRoleErrorCreateDescription": "Si è verificato un errore durante la creazione del ruolo.", "accessRoleUpdateSubmit": "Aggiorna Ruolo", "accessRoleUpdated": "Ruolo aggiornato", "accessRoleUpdatedDescription": "Il ruolo è stato aggiornato con successo.", "accessApprovalUpdated": "Approvazione trattata", "accessApprovalApprovedDescription": "Impostare la decisione di richiesta di approvazione da approvare.", "accessApprovalDeniedDescription": "Imposta la decisione di richiesta di approvazione negata.", "accessRoleErrorUpdate": "Impossibile aggiornare il ruolo", "accessRoleErrorUpdateDescription": "Si è verificato un errore nell'aggiornamento del ruolo.", "accessApprovalErrorUpdate": "Impossibile elaborare l'approvazione", "accessApprovalErrorUpdateDescription": "Si è verificato un errore durante l'elaborazione dell'approvazione.", "accessRoleErrorNewRequired": "Nuovo ruolo richiesto", "accessRoleErrorRemove": "Impossibile rimuovere il ruolo", "accessRoleErrorRemoveDescription": "Si è verificato un errore durante la rimozione del ruolo.", "accessRoleName": "Nome Del Ruolo", "accessRoleQuestionRemove": "Stai per eliminare il ruolo `{name}`. Non puoi annullare questa azione.", "accessRoleRemove": "Rimuovi Ruolo", "accessRoleRemoveDescription": "Rimuovi un ruolo dall'organizzazione", "accessRoleRemoveSubmit": "Rimuovi Ruolo", "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", "licenseTierProfessional": "Licenza Professional", "licenseTierEnterprise": "Licenza Enterprise", "licenseTierPersonal": "Licenza Personale", "licensed": "Con Licenza", "yes": "Sì", "no": "No", "sitesAdditional": "Siti Aggiuntivi", "licenseKeys": "Chiavi di Licenza", "sitestCountDecrease": "Diminuisci conteggio siti", "sitestCountIncrease": "Aumenta conteggio siti", "idpManage": "Gestisci Provider di Identità", "idpManageDescription": "Visualizza e gestisci i provider di identità nel sistema", "idpGlobalModeBanner": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Sta utilizzando IdP globali (condivisi in tutte le organizzazioni). Gestisci IdP globali nel pannello di amministrazione . Per abilitare IdP per organizzazione, modificare la configurazione del server e impostare la modalità IdP su org. Vedere i documenti. Se si desidera continuare a utilizzare IdP globali e far sparire questo dalle impostazioni dell'organizzazione, impostare esplicitamente la modalità globale nella configurazione.", "idpGlobalModeBannerUpgradeRequired": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Utilizza IdP globali (condivisi tra tutte le organizzazioni). Gestisci gli IdP globali nel pannello di amministrazione . Per utilizzare i provider di identità per organizzazione, è necessario aggiornare all'edizione Enterprise.", "idpGlobalModeBannerLicenseRequired": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Utilizza IdP globali (condivisi tra tutte le organizzazioni). Gestisci IdP globali nel pannello di amministrazione . Per utilizzare provider di identità per organizzazione, è richiesta una licenza Enterprise.", "idpDeletedDescription": "Provider di identità eliminato con successo", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Sei sicuro di voler eliminare definitivamente il provider di identità?", "idpMessageRemove": "Questo rimuoverà il provider di identità e tutte le configurazioni associate. Gli utenti che si autenticano tramite questo provider non potranno più accedere.", "idpMessageConfirm": "Per confermare, digita il nome del provider di identità qui sotto.", "idpConfirmDelete": "Conferma Eliminazione Provider di Identità", "idpDelete": "Elimina Provider di Identità", "idp": "Provider Di Identità", "idpSearch": "Cerca provider di identità...", "idpAdd": "Aggiungi Provider di Identità", "idpClientIdRequired": "L'ID client è obbligatorio.", "idpClientSecretRequired": "Il segreto client è obbligatorio.", "idpErrorAuthUrlInvalid": "L'URL di autenticazione deve essere un URL valido.", "idpErrorTokenUrlInvalid": "L'URL del token deve essere un URL valido.", "idpPathRequired": "Il percorso identificativo è obbligatorio.", "idpScopeRequired": "Gli scope sono obbligatori.", "idpOidcDescription": "Configura un provider di identità OpenID Connect", "idpCreatedDescription": "Provider di identità creato con successo", "idpCreate": "Crea Provider di Identità", "idpCreateDescription": "Configura un nuovo provider di identità per l'autenticazione degli utenti", "idpSeeAll": "Vedi Tutti i Provider di Identità", "idpSettingsDescription": "Configura le informazioni di base per il tuo provider di identità", "idpDisplayName": "Un nome visualizzato per questo provider di identità", "idpAutoProvisionUsers": "Provisioning Automatico Utenti", "idpAutoProvisionUsersDescription": "Quando abilitato, gli utenti verranno creati automaticamente nel sistema al primo accesso con la possibilità di mappare gli utenti a ruoli e organizzazioni.", "licenseBadge": "EE", "idpType": "Tipo di Provider", "idpTypeDescription": "Seleziona il tipo di provider di identità che desideri configurare", "idpOidcConfigure": "Configurazione OAuth2/OIDC", "idpOidcConfigureDescription": "Configura gli endpoint e le credenziali del provider OAuth2/OIDC", "idpClientId": "ID Client", "idpClientIdDescription": "L'ID client OAuth2 dal provider di identità", "idpClientSecret": "Segreto Client", "idpClientSecretDescription": "Il segreto del client OAuth2 dal provider di identità", "idpAuthUrl": "URL di Autorizzazione", "idpAuthUrlDescription": "L'URL dell'endpoint di autorizzazione OAuth2", "idpTokenUrl": "URL del Token", "idpTokenUrlDescription": "L'URL dell'endpoint del token OAuth2", "idpOidcConfigureAlert": "Informazioni Importanti", "idpOidcConfigureAlertDescription": "Dopo aver creato il provider di identità, è necessario configurare l'URL di callback nelle impostazioni del provider di identità. L'URL di callback verrà fornito dopo la creazione riuscita.", "idpToken": "Configurazione Token", "idpTokenDescription": "Configura come estrarre le informazioni dell'utente dal token ID", "idpJmespathAbout": "Informazioni su JMESPath", "idpJmespathAboutDescription": "I percorsi sottostanti utilizzano la sintassi JMESPath per estrarre valori dal token ID.", "idpJmespathAboutDescriptionLink": "Scopri di più su JMESPath", "idpJmespathLabel": "Percorso Identificativo", "idpJmespathLabelDescription": "Il JMESPath per l'identificatore dell'utente nel token ID", "idpJmespathEmailPathOptional": "Percorso Email (Opzionale)", "idpJmespathEmailPathOptionalDescription": "Il JMESPath per l'email dell'utente nel token ID", "idpJmespathNamePathOptional": "Percorso Nome (Opzionale)", "idpJmespathNamePathOptionalDescription": "Il JMESPath per il nome dell'utente nel token ID", "idpOidcConfigureScopes": "Scope", "idpOidcConfigureScopesDescription": "Lista degli scope OAuth2 da richiedere separati da spazi", "idpSubmit": "Crea Provider di Identità", "orgPolicies": "Politiche Organizzazione", "idpSettings": "Impostazioni {idpName}", "idpCreateSettingsDescription": "Configura le impostazioni per il provider di identità", "roleMapping": "Mappatura Ruoli", "orgMapping": "Mappatura Organizzazione", "orgPoliciesSearch": "Cerca politiche organizzazione...", "orgPoliciesAdd": "Aggiungi Politica Organizzazione", "orgRequired": "L'organizzazione è obbligatoria", "error": "Errore", "success": "Successo", "orgPolicyAddedDescription": "Politica aggiunta con successo", "orgPolicyUpdatedDescription": "Politica aggiornata con successo", "orgPolicyDeletedDescription": "Politica eliminata con successo", "defaultMappingsUpdatedDescription": "Mappature predefinite aggiornate con successo", "orgPoliciesAbout": "Informazioni sulle Politiche Organizzazione", "orgPoliciesAboutDescription": "Le politiche organizzazione sono utilizzate per controllare l'accesso alle organizzazioni in base al token ID dell'utente. Puoi specificare espressioni JMESPath per estrarre informazioni su ruoli e organizzazioni dal token ID. Per maggiori informazioni, vedi", "orgPoliciesAboutDescriptionLink": "la documentazione", "defaultMappingsOptional": "Mappature Predefinite (Opzionale)", "defaultMappingsOptionalDescription": "Le mappature predefinite sono utilizzate quando non esiste una politica organizzazione definita per un'organizzazione. Puoi specificare qui le mappature predefinite di ruolo e organizzazione da utilizzare come fallback.", "defaultMappingsRole": "Mappatura Ruolo Predefinito", "defaultMappingsRoleDescription": "JMESPath per estrarre informazioni sul ruolo dal token ID. Il risultato di questa espressione deve restituire il nome del ruolo come definito nell'organizzazione come stringa.", "defaultMappingsOrg": "Mappatura Organizzazione Predefinita", "defaultMappingsOrgDescription": "JMESPath per estrarre informazioni sull'organizzazione dal token ID. Questa espressione deve restituire l'ID dell'organizzazione o true affinché l'utente possa accedere all'organizzazione.", "defaultMappingsSubmit": "Salva Mappature Predefinite", "orgPoliciesEdit": "Modifica Politica Organizzazione", "org": "Organizzazione", "orgSelect": "Seleziona organizzazione", "orgSearch": "Cerca organizzazione", "orgNotFound": "Nessuna organizzazione trovata.", "roleMappingPathOptional": "Percorso Mappatura Ruolo (Opzionale)", "orgMappingPathOptional": "Percorso Mappatura Organizzazione (Opzionale)", "orgPolicyUpdate": "Aggiorna Politica", "orgPolicyAdd": "Aggiungi Politica", "orgPolicyConfig": "Configura l'accesso per un'organizzazione", "idpUpdatedDescription": "Provider di identità aggiornato con successo", "redirectUrl": "URL di Reindirizzamento", "orgIdpRedirectUrls": "Reindirizza URL", "redirectUrlAbout": "Informazioni sull'URL di Reindirizzamento", "redirectUrlAboutDescription": "Questo è l'URL a cui gli utenti saranno reindirizzati dopo l'autenticazione. È necessario configurare questo URL nelle impostazioni del provider di identità.", "pangolinAuth": "Autenticazione - Pangolina", "verificationCodeLengthRequirements": "Il tuo codice di verifica deve essere di 8 caratteri.", "errorOccurred": "Si è verificato un errore", "emailErrorVerify": "Impossibile verificare l'email:", "emailVerified": "Email verificata con successo! Reindirizzamento in corso...", "verificationCodeErrorResend": "Impossibile reinviare il codice di verifica:", "verificationCodeResend": "Codice di verifica reinviato", "verificationCodeResendDescription": "Abbiamo reinviato un codice di verifica al tuo indirizzo email. Controlla la tua casella di posta.", "emailVerify": "Verifica Email", "emailVerifyDescription": "Inserisci il codice di verifica inviato al tuo indirizzo email.", "verificationCode": "Codice di Verifica", "verificationCodeEmailSent": "Abbiamo inviato un codice di verifica al tuo indirizzo email.", "submit": "Invia", "emailVerifyResendProgress": "Reinvio in corso...", "emailVerifyResend": "Non hai ricevuto il codice? Clicca qui per reinviare", "passwordNotMatch": "Le password non coincidono", "signupError": "Si è verificato un errore durante la registrazione", "pangolinLogoAlt": "Logo Pangolin", "inviteAlready": "Sembra che sei stato invitato!", "inviteAlreadyDescription": "Per accettare l'invito, devi accedere o creare un account.", "signupQuestion": "Hai già un account?", "login": "Log In", "resourceNotFound": "Risorsa Non Trovata", "resourceNotFoundDescription": "La risorsa che stai cercando di accedere non esiste.", "pincodeRequirementsLength": "Il PIN deve essere esattamente di 6 cifre", "pincodeRequirementsChars": "Il PIN deve contenere solo numeri", "passwordRequirementsLength": "La password deve essere lunga almeno 1 carattere", "passwordRequirementsTitle": "Requisiti della password:", "passwordRequirementLength": "Almeno 8 caratteri", "passwordRequirementUppercase": "Almeno una lettera maiuscola", "passwordRequirementLowercase": "Almeno una lettera minuscola", "passwordRequirementNumber": "Almeno un numero", "passwordRequirementSpecial": "Almeno un carattere speciale", "passwordRequirementsMet": "✓ La password soddisfa tutti i requisiti", "passwordStrength": "Forza della password", "passwordStrengthWeak": "Debole", "passwordStrengthMedium": "Media", "passwordStrengthStrong": "Forte", "passwordRequirements": "Requisiti:", "passwordRequirementLengthText": "8+ caratteri", "passwordRequirementUppercaseText": "Lettera maiuscola (A-Z)", "passwordRequirementLowercaseText": "Lettera minuscola (a-z)", "passwordRequirementNumberText": "Numero (0-9)", "passwordRequirementSpecialText": "Carattere speciale (!@#$%...)", "passwordsDoNotMatch": "Le password non coincidono", "otpEmailRequirementsLength": "L'OTP deve essere lungo almeno 1 carattere", "otpEmailSent": "OTP Inviato", "otpEmailSentDescription": "Un OTP è stato inviato alla tua email", "otpEmailErrorAuthenticate": "Impossibile autenticare con l'email", "pincodeErrorAuthenticate": "Impossibile autenticare con il codice PIN", "passwordErrorAuthenticate": "Impossibile autenticare con la password", "poweredBy": "Offerto da", "authenticationRequired": "Autenticazione Richiesta", "authenticationMethodChoose": "Scegli il tuo metodo preferito per accedere a {name}", "authenticationRequest": "Devi autenticarti per accedere a {name}", "user": "Utente", "pincodeInput": "Codice PIN a 6 cifre", "pincodeSubmit": "Accedi con PIN", "passwordSubmit": "Accedi con Password", "otpEmailDescription": "Un codice usa e getta verrà inviato a questa email.", "otpEmailSend": "Invia Codice Usa e Getta", "otpEmail": "Password Usa e Getta (OTP)", "otpEmailSubmit": "Invia OTP", "backToEmail": "Torna all'Email", "noSupportKey": "Il server è in esecuzione senza una chiave di supporto. Considera di supportare il progetto!", "accessDenied": "Accesso Negato", "accessDeniedDescription": "Non sei autorizzato ad accedere a questa risorsa. Se ritieni che sia un errore, contatta l'amministratore.", "accessTokenError": "Errore nel controllo del token di accesso", "accessGranted": "Accesso Concesso", "accessUrlInvalid": "URL di Accesso Non Valido", "accessGrantedDescription": "Ti è stato concesso l'accesso a questa risorsa. Reindirizzamento in corso...", "accessUrlInvalidDescription": "Questo URL di accesso condiviso non è valido. Contatta il proprietario della risorsa per un nuovo URL.", "tokenInvalid": "Token non valido", "pincodeInvalid": "Codice non valido", "passwordErrorRequestReset": "Impossibile richiedere il reset:", "passwordErrorReset": "Impossibile reimpostare la password:", "passwordResetSuccess": "Password reimpostata con successo! Torna al login...", "passwordReset": "Reimposta Password", "passwordResetDescription": "Segui i passaggi per reimpostare la tua password", "passwordResetSent": "Invieremo un codice di reset della password a questo indirizzo email.", "passwordResetCode": "Codice di Reset", "passwordResetCodeDescription": "Controlla la tua email per il codice di reset.", "generatePasswordResetCode": "Genera Codice Di Ripristino Password", "passwordResetCodeGenerated": "Codice Di Reimpostazione Password Generato", "passwordResetCodeGeneratedDescription": "Condividi questo codice con l'utente. Possono usarlo per reimpostare la password.", "passwordResetUrl": "Reset URL", "passwordNew": "Nuova Password", "passwordNewConfirm": "Conferma Nuova Password", "changePassword": "Cambia Password", "changePasswordDescription": "Aggiorna la password del tuo account", "oldPassword": "Password Attuale", "newPassword": "Nuova Password", "confirmNewPassword": "Conferma Nuova Password", "changePasswordError": "Impossibile cambiare la password", "changePasswordErrorDescription": "Si è verificato un errore durante la modifica della password", "changePasswordSuccess": "Password Cambiata Con Successo", "changePasswordSuccessDescription": "La password è stata aggiornata con successo", "passwordExpiryRequired": "Scadenza Password Richiesta", "passwordExpiryDescription": "Questa organizzazione richiede di cambiare la password ogni {maxDays} giorni.", "changePasswordNow": "Cambia Password Ora", "pincodeAuth": "Codice Autenticatore", "pincodeSubmit2": "Invia codice", "passwordResetSubmit": "Richiedi Reset", "passwordResetAlreadyHaveCode": "Inserisci Codice", "passwordResetSmtpRequired": "Si prega di contattare l'amministratore", "passwordResetSmtpRequiredDescription": "Per reimpostare la password è necessario un codice di reimpostazione della password. Si prega di contattare l'amministratore per assistenza.", "passwordBack": "Torna alla Password", "loginBack": "Torna alla pagina di accesso principale", "signup": "Registrati", "loginStart": "Accedi per iniziare", "idpOidcTokenValidating": "Convalida token OIDC", "idpOidcTokenResponse": "Convalida risposta token OIDC", "idpErrorOidcTokenValidating": "Errore nella convalida del token OIDC", "idpConnectingTo": "Connessione a {name}", "idpConnectingToDescription": "Convalida della tua identità", "idpConnectingToProcess": "Connessione in corso...", "idpConnectingToFinished": "Connesso", "idpErrorConnectingTo": "Si è verificato un problema durante la connessione a {name}. Contatta il tuo amministratore.", "idpErrorNotFound": "IdP non trovato", "inviteInvalid": "Invito Non Valido", "inviteInvalidDescription": "Il link di invito non è valido.", "inviteErrorWrongUser": "L'invito non è per questo utente", "inviteErrorUserNotExists": "L'utente non esiste. Si prega di creare prima un account.", "inviteErrorLoginRequired": "Devi effettuare l'accesso per accettare un invito", "inviteErrorExpired": "L'invito potrebbe essere scaduto", "inviteErrorRevoked": "L'invito potrebbe essere stato revocato", "inviteErrorTypo": "Potrebbe esserci un errore di battitura nel link di invito", "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.", "overview": "Panoramica", "home": "Home", "settings": "Impostazioni", "usersAll": "Tutti Gli Utenti", "license": "Licenza", "pangolinDashboard": "Cruscotto - Pangolino", "noResults": "Nessun risultato trovato.", "terabytes": "{count} TB", "gigabytes": "{count}GB", "megabytes": "{count} MB", "tagsEntered": "Tag Inseriti", "tagsEnteredDescription": "Questi sono i tag che hai inserito.", "tagsWarnCannotBeLessThanZero": "maxTags e minTags non possono essere minori di 0", "tagsWarnNotAllowedAutocompleteOptions": "Tag non consentito come da opzioni di autocompletamento", "tagsWarnInvalid": "Tag non valido secondo validateTag", "tagWarnTooShort": "Il tag {tagText} è troppo corto", "tagWarnTooLong": "Il tag {tagText} è troppo lungo", "tagsWarnReachedMaxNumber": "Raggiunto il numero massimo di tag consentiti", "tagWarnDuplicate": "Tag duplicato {tagText} non aggiunto", "supportKeyInvalid": "Chiave Non Valida", "supportKeyInvalidDescription": "La tua chiave di supporto non è valida.", "supportKeyValid": "Chiave Valida", "supportKeyValidDescription": "La tua chiave di supporto è stata convalidata. Grazie per il tuo sostegno!", "supportKeyErrorValidationDescription": "Impossibile convalidare la chiave di supporto.", "supportKey": "Supporta lo Sviluppo e Adotta un Pangolino!", "supportKeyDescription": "Acquista una chiave di supporto per aiutarci a continuare a sviluppare Pangolin per la comunità. Il tuo contributo ci permette di dedicare più tempo alla manutenzione e all'aggiunta di nuove funzionalità per tutti. Non useremo mai questo per bloccare le funzionalità. Questo è separato da qualsiasi Edizione Commerciale.", "supportKeyPet": "Potrai anche adottare e incontrare il tuo pangolino personale!", "supportKeyPurchase": "I pagamenti sono elaborati tramite GitHub. Successivamente, potrai recuperare la tua chiave su", "supportKeyPurchaseLink": "il nostro sito web", "supportKeyPurchase2": "e riscattarla qui.", "supportKeyLearnMore": "Scopri di più.", "supportKeyOptions": "Seleziona l'opzione più adatta a te.", "supportKetOptionFull": "Supporto Completo", "forWholeServer": "Per l'intero server", "lifetimePurchase": "Acquisto a vita", "supporterStatus": "Stato supportatore", "buy": "Acquista", "supportKeyOptionLimited": "Supporto Limitato", "forFiveUsers": "Per 5 o meno utenti", "supportKeyRedeem": "Riscatta Chiave di Supporto", "supportKeyHideSevenDays": "Nascondi per 7 giorni", "supportKeyEnter": "Inserisci Chiave di Supporto", "supportKeyEnterDescription": "Incontra il tuo pangolino personale!", "githubUsername": "Username GitHub", "supportKeyInput": "Chiave di Supporto", "supportKeyBuy": "Acquista Chiave di Supporto", "logoutError": "Errore durante il logout", "signingAs": "Accesso come", "serverAdmin": "Amministratore Server", "managedSelfhosted": "Gestito Auto-Ospitato", "otpEnable": "Abilita Autenticazione a Due Fattori", "otpDisable": "Disabilita Autenticazione a Due Fattori", "logout": "Disconnetti", "licenseTierProfessionalRequired": "Edizione Professional Richiesta", "licenseTierProfessionalRequiredDescription": "Questa funzionalità è disponibile solo nell'Edizione Professional.", "actionGetOrg": "Ottieni Organizzazione", "updateOrgUser": "Aggiorna Utente Org", "createOrgUser": "Crea Utente Org", "actionUpdateOrg": "Aggiorna Organizzazione", "actionRemoveInvitation": "Rimuovi Invito", "actionUpdateUser": "Aggiorna Utente", "actionGetUser": "Ottieni Utente", "actionGetOrgUser": "Ottieni Utente Organizzazione", "actionListOrgDomains": "Elenca Domini Organizzazione", "actionGetDomain": "Ottieni Dominio", "actionCreateOrgDomain": "Crea Dominio", "actionUpdateOrgDomain": "Aggiorna Dominio", "actionDeleteOrgDomain": "Elimina Dominio", "actionGetDNSRecords": "Ottieni Record DNS", "actionRestartOrgDomain": "Riavvia Dominio", "actionCreateSite": "Crea Sito", "actionDeleteSite": "Elimina Sito", "actionGetSite": "Ottieni Sito", "actionListSites": "Elenca Siti", "actionApplyBlueprint": "Applica Progetto", "actionListBlueprints": "Elenco Blueprints", "actionGetBlueprint": "Ottieni Blueprint", "setupToken": "Configura Token", "setupTokenDescription": "Inserisci il token di configurazione dalla console del server.", "setupTokenRequired": "Il token di configurazione è richiesto", "actionUpdateSite": "Aggiorna Sito", "actionListSiteRoles": "Elenca Ruoli Sito Consentiti", "actionCreateResource": "Crea Risorsa", "actionDeleteResource": "Elimina Risorsa", "actionGetResource": "Ottieni Risorsa", "actionListResource": "Elenca Risorse", "actionUpdateResource": "Aggiorna Risorsa", "actionListResourceUsers": "Elenca Utenti Risorsa", "actionSetResourceUsers": "Imposta Utenti Risorsa", "actionSetAllowedResourceRoles": "Imposta Ruoli Risorsa Consentiti", "actionListAllowedResourceRoles": "Elenca Ruoli Risorsa Consentiti", "actionSetResourcePassword": "Imposta Password Risorsa", "actionSetResourcePincode": "Imposta Codice PIN Risorsa", "actionSetResourceEmailWhitelist": "Imposta Lista Autorizzazioni Email Risorsa", "actionGetResourceEmailWhitelist": "Ottieni Lista Autorizzazioni Email Risorsa", "actionCreateTarget": "Crea Target", "actionDeleteTarget": "Elimina Target", "actionGetTarget": "Ottieni Target", "actionListTargets": "Elenca Target", "actionUpdateTarget": "Aggiorna Target", "actionCreateRole": "Crea Ruolo", "actionDeleteRole": "Elimina Ruolo", "actionGetRole": "Ottieni Ruolo", "actionListRole": "Elenca Ruoli", "actionUpdateRole": "Aggiorna Ruolo", "actionListAllowedRoleResources": "Elenca Risorse Ruolo Consentite", "actionInviteUser": "Invita Utente", "actionRemoveUser": "Rimuovi Utente", "actionListUsers": "Elenca Utenti", "actionAddUserRole": "Aggiungi Ruolo Utente", "actionGenerateAccessToken": "Genera Token di Accesso", "actionDeleteAccessToken": "Elimina Token di Accesso", "actionListAccessTokens": "Elenca Token di Accesso", "actionCreateResourceRule": "Crea Regola Risorsa", "actionDeleteResourceRule": "Elimina Regola Risorsa", "actionListResourceRules": "Elenca Regole Risorsa", "actionUpdateResourceRule": "Aggiorna Regola Risorsa", "actionListOrgs": "Elenca Organizzazioni", "actionCheckOrgId": "Controlla ID", "actionCreateOrg": "Crea Organizzazione", "actionDeleteOrg": "Elimina Organizzazione", "actionListApiKeys": "Elenca Chiavi API", "actionListApiKeyActions": "Elenca Azioni Chiave API", "actionSetApiKeyActions": "Imposta Azioni Consentite Chiave API", "actionCreateApiKey": "Crea Chiave API", "actionDeleteApiKey": "Elimina Chiave API", "actionCreateIdp": "Crea IDP", "actionUpdateIdp": "Aggiorna IDP", "actionDeleteIdp": "Elimina IDP", "actionListIdps": "Elenca IDP", "actionGetIdp": "Ottieni IDP", "actionCreateIdpOrg": "Crea Politica Org IDP", "actionDeleteIdpOrg": "Elimina Politica Org IDP", "actionListIdpOrgs": "Elenca Org IDP", "actionUpdateIdpOrg": "Aggiorna Org IDP", "actionCreateClient": "Crea Client", "actionDeleteClient": "Elimina Client", "actionArchiveClient": "Archivia Client", "actionUnarchiveClient": "Annulla Archiviazione Client", "actionBlockClient": "Blocca Client", "actionUnblockClient": "Sblocca Client", "actionUpdateClient": "Aggiorna Client", "actionListClients": "Elenco Clienti", "actionGetClient": "Ottieni Client", "actionCreateSiteResource": "Crea Risorsa del Sito", "actionDeleteSiteResource": "Elimina Risorsa del Sito", "actionGetSiteResource": "Ottieni Risorsa del Sito", "actionListSiteResources": "Elenca Risorse del Sito", "actionUpdateSiteResource": "Aggiorna Risorsa del Sito", "actionListInvitations": "Elenco Inviti", "actionExportLogs": "Esporta Log", "actionViewLogs": "Visualizza Log", "noneSelected": "Nessuna selezione", "orgNotFound2": "Nessuna organizzazione trovata.", "searchPlaceholder": "Cerca...", "emptySearchOptions": "Nessuna opzione trovata", "create": "Crea", "orgs": "Organizzazioni", "loginError": "Si è verificato un errore imprevisto. Riprova.", "loginRequiredForDevice": "Il login è richiesto per il tuo dispositivo.", "passwordForgot": "Password dimenticata?", "otpAuth": "Autenticazione a Due Fattori", "otpAuthDescription": "Inserisci il codice dalla tua app di autenticazione o uno dei tuoi codici di backup monouso.", "otpAuthSubmit": "Invia Codice", "idpContinue": "O continua con", "otpAuthBack": "Torna alla Password", "navbar": "Menu di Navigazione", "navbarDescription": "Menu di navigazione principale dell'applicazione", "navbarDocsLink": "Documentazione", "otpErrorEnable": "Impossibile abilitare 2FA", "otpErrorEnableDescription": "Si è verificato un errore durante l'abilitazione di 2FA", "otpSetupCheckCode": "Inserisci un codice a 6 cifre", "otpSetupCheckCodeRetry": "Codice non valido. Riprova.", "otpSetup": "Abilita Autenticazione a Due Fattori", "otpSetupDescription": "Proteggi il tuo account con un livello extra di protezione", "otpSetupScanQr": "Scansiona questo codice QR con la tua app di autenticazione o inserisci manualmente la chiave segreta:", "otpSetupSecretCode": "Codice Autenticatore", "otpSetupSuccess": "Autenticazione a Due Fattori Abilitata", "otpSetupSuccessStoreBackupCodes": "Il tuo account è ora più sicuro. Non dimenticare di salvare i tuoi codici di backup.", "otpErrorDisable": "Impossibile disabilitare 2FA", "otpErrorDisableDescription": "Si è verificato un errore durante la disabilitazione di 2FA", "otpRemove": "Disabilita Autenticazione a Due Fattori", "otpRemoveDescription": "Disabilita l'autenticazione a due fattori per il tuo account", "otpRemoveSuccess": "Autenticazione a Due Fattori Disabilitata", "otpRemoveSuccessMessage": "L'autenticazione a due fattori è stata disabilitata per il tuo account. Puoi riattivarla in qualsiasi momento.", "otpRemoveSubmit": "Disabilita 2FA", "paginator": "Pagina {current} di {last}", "paginatorToFirst": "Vai alla prima pagina", "paginatorToPrevious": "Vai alla pagina precedente", "paginatorToNext": "Vai alla pagina successiva", "paginatorToLast": "Vai all'ultima pagina", "copyText": "Copia testo", "copyTextFailed": "Impossibile copiare il testo: ", "copyTextClipboard": "Copia negli appunti", "inviteErrorInvalidConfirmation": "Conferma non valida", "passwordRequired": "La password è obbligatoria", "allowAll": "Consenti Tutto", "permissionsAllowAll": "Consenti Tutti I Permessi", "githubUsernameRequired": "È richiesto l'username GitHub", "supportKeyRequired": "È richiesta la chiave di supporto", "passwordRequirementsChars": "La password deve essere di almeno 8 caratteri", "language": "Lingua", "verificationCodeRequired": "È richiesto un codice", "userErrorNoUpdate": "Nessun utente da aggiornare", "siteErrorNoUpdate": "Nessun sito da aggiornare", "resourceErrorNoUpdate": "Nessuna risorsa da aggiornare", "authErrorNoUpdate": "Nessuna informazione di autenticazione da aggiornare", "orgErrorNoUpdate": "Nessuna organizzazione da aggiornare", "orgErrorNoProvided": "Nessuna organizzazione fornita", "apiKeysErrorNoUpdate": "Nessuna chiave API da aggiornare", "sidebarOverview": "Panoramica", "sidebarHome": "Home", "sidebarSites": "Siti", "sidebarApprovals": "Richieste Di Approvazione", "sidebarResources": "Risorse", "sidebarProxyResources": "Pubblico", "sidebarClientResources": "Privato", "sidebarAccessControl": "Controllo Accesso", "sidebarLogsAndAnalytics": "Registri E Analisi", "sidebarTeam": "Squadra", "sidebarUsers": "Utenti", "sidebarAdmin": "Amministratore", "sidebarInvitations": "Inviti", "sidebarRoles": "Ruoli", "sidebarShareableLinks": "Collegamenti", "sidebarApiKeys": "Chiavi API", "sidebarSettings": "Impostazioni", "sidebarAllUsers": "Tutti Gli Utenti", "sidebarIdentityProviders": "Fornitori Di Identità", "sidebarLicense": "Licenza", "sidebarClients": "Client", "sidebarUserDevices": "Dispositivi Utente", "sidebarMachineClients": "Macchine", "sidebarDomains": "Domini", "sidebarGeneral": "Gestisci", "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", "blueprintAdd": "Aggiungi Progetto", "blueprintGoBack": "Vedi tutti i progetti", "blueprintCreate": "Crea Progetto", "blueprintCreateDescription2": "Segui i passaggi qui sotto per creare e applicare un nuovo progetto", "blueprintDetails": "Dettagli Progetto", "blueprintDetailsDescription": "Vedere il risultato del progetto applicato e gli eventuali errori verificatisi", "blueprintInfo": "Informazioni Sul Progetto", "message": "Messaggio", "blueprintContentsDescription": "Definire il contenuto YAML che descrive l'infrastruttura", "blueprintErrorCreateDescription": "Si è verificato un errore durante l'applicazione del progetto", "blueprintErrorCreate": "Errore nella creazione del progetto", "searchBlueprintProgress": "Cerca progetti...", "appliedAt": "Applicato Il", "source": "Fonte", "contents": "Contenuti", "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.", "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.", "containerName": "Nome", "containerImage": "Immagine", "containerState": "Stato", "containerNetworks": "Reti", "containerHostnameIp": "Hostname/IP", "containerLabels": "Etichette", "containerLabelsCount": "{count, plural, one {# etichetta} other {# etichette}}", "containerLabelsTitle": "Etichette Del Contenitore", "containerLabelEmpty": "", "containerPorts": "Porte", "containerPortsMore": "+{count} in più", "containerActions": "Azioni", "select": "Seleziona", "noContainersMatchingFilters": "Nessun contenitore trovato corrispondente ai filtri correnti.", "showContainersWithoutPorts": "Mostra contenitori senza porte", "showStoppedContainers": "Mostra contenitori fermati", "noContainersFound": "Nessun contenitore trovato. Assicurarsi che i contenitori Docker siano in esecuzione.", "searchContainersPlaceholder": "Cerca tra i contenitori {count}...", "searchResultsCount": "{count, plural, one {# risultato} other {# risultati}}", "filters": "Filtri", "filterOptions": "Opzioni Filtro", "filterPorts": "Porte", "filterStopped": "Fermato", "clearAllFilters": "Cancella tutti i filtri", "columns": "Colonne", "toggleColumns": "Attiva/Disattiva Colonne", "refreshContainersList": "Aggiorna elenco contenitori", "searching": "Ricerca...", "noContainersFoundMatching": "Nessun contenitore trovato corrispondente \"{filter}\".", "light": "chiaro", "dark": "scuro", "system": "sistema", "theme": "Tema", "subnetRequired": "Sottorete richiesta", "initialSetupTitle": "Impostazione Iniziale del Server", "initialSetupDescription": "Crea l'account amministratore del server iniziale. Può esistere solo un amministratore del server. È sempre possibile modificare queste credenziali in seguito.", "createAdminAccount": "Crea Account Admin", "setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.", "certificateStatus": "Stato del Certificato", "loading": "Caricamento", "loadingAnalytics": "Caricamento Delle Analisi", "restart": "Riavvia", "domains": "Domini", "domainsDescription": "Creare e gestire i domini disponibili nell'organizzazione", "domainsSearch": "Cerca domini...", "domainAdd": "Aggiungi Dominio", "domainAddDescription": "Registra un nuovo dominio con all'organizzazione", "domainCreate": "Crea Dominio", "domainCreatedDescription": "Dominio creato con successo", "domainDeletedDescription": "Dominio eliminato con successo", "domainQuestionRemove": "Sei sicuro di voler rimuovere il dominio?", "domainMessageRemove": "Una volta rimosso, il dominio non sarà più associato all'organizzazione.", "domainConfirmDelete": "Conferma Eliminazione Dominio", "domainDelete": "Elimina Dominio", "domain": "Dominio", "selectDomainTypeNsName": "Delega Dominio (NS)", "selectDomainTypeNsDescription": "Questo dominio e tutti i suoi sottodomini. Usa questo quando desideri controllare un'intera zona di dominio.", "selectDomainTypeCnameName": "Dominio Singolo (CNAME)", "selectDomainTypeCnameDescription": "Solo questo dominio specifico. Usa questo per sottodomini individuali o specifiche voci di dominio.", "selectDomainTypeWildcardName": "Dominio Jolly", "selectDomainTypeWildcardDescription": "Questo dominio e i suoi sottodomini.", "domainDelegation": "Dominio Singolo", "selectType": "Seleziona un tipo", "actions": "Azioni", "refresh": "Aggiorna", "refreshError": "Impossibile aggiornare i dati", "verified": "Verificato", "pending": "In attesa", "pendingApproval": "Approvazione In Attesa", "sidebarBilling": "Fatturazione", "billing": "Fatturazione", "orgBillingDescription": "Gestisci le informazioni di fatturazione e gli abbonamenti", "github": "GitHub", "pangolinHosted": "Pangolin Hosted", "fossorial": "Fossoriale", "completeAccountSetup": "Completa la Configurazione dell'Account", "completeAccountSetupDescription": "Imposta la tua password per iniziare", "accountSetupSent": "Invieremo un codice di configurazione dell'account a questo indirizzo email.", "accountSetupCode": "Codice di Configurazione", "accountSetupCodeDescription": "Controlla la tua email per il codice di configurazione.", "passwordCreate": "Crea Password", "passwordCreateConfirm": "Conferma Password", "accountSetupSubmit": "Invia Codice di Configurazione", "completeSetup": "Completa la Configurazione", "accountSetupSuccess": "Configurazione dell'account completata! Benvenuto su Pangolin!", "documentation": "Documentazione", "saveAllSettings": "Salva Tutte le Impostazioni", "saveResourceTargets": "Salva Target", "saveResourceHttp": "Salva Impostazioni Proxy", "saveProxyProtocol": "Salva impostazioni protocollo proxy", "settingsUpdated": "Impostazioni aggiornate", "settingsUpdatedDescription": "Impostazioni aggiornate con successo", "settingsErrorUpdate": "Impossibile aggiornare le impostazioni", "settingsErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento delle impostazioni", "sidebarCollapse": "Comprimi", "sidebarExpand": "Espandi", "productUpdateMoreInfo": "{noOfUpdates} altri aggiornamenti", "productUpdateInfo": "{noOfUpdates} aggiornamenti", "productUpdateWhatsNew": "Novità", "productUpdateTitle": "Aggiornamenti Prodotto", "productUpdateEmpty": "Nessun aggiornamento", "dismissAll": "Ignora tutto", "pangolinUpdateAvailable": "Aggiornamento Disponibile", "pangolinUpdateAvailableInfo": "La versione {version} è pronta per l'installazione", "pangolinUpdateAvailableReleaseNotes": "Visualizza Note Di Rilascio", "newtUpdateAvailable": "Aggiornamento Disponibile", "newtUpdateAvailableInfo": "È disponibile una nuova versione di Newt. Si prega di aggiornare all'ultima versione per la migliore esperienza.", "domainPickerEnterDomain": "Dominio", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Inserisci il dominio completo della risorsa per vedere le opzioni disponibili.", "domainPickerDescriptionSaas": "Inserisci un dominio completo, un sottodominio o semplicemente un nome per vedere le opzioni disponibili", "domainPickerTabAll": "Tutti", "domainPickerTabOrganization": "Organizzazione", "domainPickerTabProvided": "Fornito", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Controllando la disponibilità...", "domainPickerNoMatchingDomains": "Nessun dominio corrispondente trovato. Prova un dominio diverso o controlla le impostazioni del dominio dell'organizzazione.", "domainPickerOrganizationDomains": "Domini dell'Organizzazione", "domainPickerProvidedDomains": "Domini Forniti", "domainPickerSubdomain": "Sottodominio: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mostra Altro", "regionSelectorTitle": "Seleziona regione", "regionSelectorInfo": "Selezionare una regione ci aiuta a fornire migliori performance per la tua posizione. Non devi necessariamente essere nella stessa regione del tuo server.", "regionSelectorPlaceholder": "Scegli una regione", "regionSelectorComingSoon": "Prossimamente", "billingLoadingSubscription": "Caricamento abbonamento...", "billingFreeTier": "Piano Gratuito", "billingWarningOverLimit": "Avviso: Hai superato uno o più limiti di utilizzo. I tuoi siti non si connetteranno finché non modifichi il tuo abbonamento o non adegui il tuo utilizzo.", "billingUsageLimitsOverview": "Panoramica dei Limiti di Utilizzo", "billingMonitorUsage": "Monitora il tuo utilizzo rispetto ai limiti configurati. Se hai bisogno di aumentare i limiti, contattaci all'indirizzo support@pangolin.net.", "billingDataUsage": "Utilizzo dei Dati", "billingSites": "Siti", "billingUsers": "Utenti", "billingDomains": "Domini", "billingOrganizations": "Organi", "billingRemoteExitNodes": "Nodi Remoti", "billingNoLimitConfigured": "Nessun limite configurato", "billingEstimatedPeriod": "Periodo di Fatturazione Stimato", "billingIncludedUsage": "Utilizzo Incluso", "billingIncludedUsageDescription": "Utilizzo incluso nel tuo piano di abbonamento corrente", "billingFreeTierIncludedUsage": "Elenchi di utilizzi inclusi nel piano gratuito", "billingIncluded": "incluso", "billingEstimatedTotal": "Totale Stimato:", "billingNotes": "Note", "billingEstimateNote": "Questa è una stima basata sul tuo utilizzo attuale.", "billingActualChargesMayVary": "I costi effettivi possono variare.", "billingBilledAtEnd": "Sarai fatturato alla fine del periodo di fatturazione.", "billingModifySubscription": "Modifica Abbonamento", "billingStartSubscription": "Inizia Abbonamento", "billingRecurringCharge": "Addebito Ricorrente", "billingManageSubscriptionSettings": "Gestisci impostazioni e preferenze dell'abbonamento", "billingNoActiveSubscription": "Non hai un abbonamento attivo. Avvia il tuo abbonamento per aumentare i limiti di utilizzo.", "billingFailedToLoadSubscription": "Caricamento abbonamento fallito", "billingFailedToLoadUsage": "Caricamento utilizzo fallito", "billingFailedToGetCheckoutUrl": "Errore durante l'ottenimento dell'URL di pagamento", "billingPleaseTryAgainLater": "Per favore, riprova più tardi.", "billingCheckoutError": "Errore di Pagamento", "billingFailedToGetPortalUrl": "Errore durante l'ottenimento dell'URL del portale", "billingPortalError": "Errore del Portale", "billingDataUsageInfo": "Hai addebitato tutti i dati trasferiti attraverso i tunnel sicuri quando sei connesso al cloud. Questo include sia il traffico in entrata e in uscita attraverso tutti i siti. Quando si raggiunge il limite, i siti si disconnetteranno fino a quando non si aggiorna il piano o si riduce l'utilizzo. I dati non vengono caricati quando si utilizzano nodi.", "billingSInfo": "Quanti siti puoi usare", "billingUsersInfo": "Quanti utenti puoi usare", "billingDomainInfo": "Quanti domini puoi usare", "billingRemoteExitNodesInfo": "Quanti nodi remoti puoi usare", "billingLicenseKeys": "Chiavi di Licenza", "billingLicenseKeysDescription": "Gestisci le sottoscrizioni alla chiave di licenza", "billingLicenseSubscription": "Abbonamento Licenza", "billingInactive": "Inattivo", "billingLicenseItem": "Elemento Licenza", "billingQuantity": "Quantità", "billingTotal": "totale", "billingModifyLicenses": "Modifica Abbonamento Licenza", "domainNotFound": "Domini Non Trovati", "domainNotFoundDescription": "Questa risorsa è disabilitata perché il dominio non esiste più nel nostro sistema. Si prega di impostare un nuovo dominio per questa risorsa.", "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", "securityKeyRegister": "Registra nuova chiave di sicurezza", "securityKeyList": "Le tue chiavi di sicurezza", "securityKeyNone": "Nessuna chiave di sicurezza registrata", "securityKeyNameRequired": "Il nome è obbligatorio", "securityKeyRemove": "Rimuovi", "securityKeyLastUsed": "Ultimo utilizzo: {date}", "securityKeyNameLabel": "Nome", "securityKeyRegisterSuccess": "Chiave di sicurezza registrata con successo", "securityKeyRegisterError": "Errore durante la registrazione della chiave di sicurezza", "securityKeyRemoveSuccess": "Chiave di sicurezza rimossa con successo", "securityKeyRemoveError": "Errore durante la rimozione della chiave di sicurezza", "securityKeyLoadError": "Errore durante il caricamento delle chiavi di sicurezza", "securityKeyLogin": "Usa Chiave Di Sicurezza", "securityKeyAuthError": "Errore durante l'autenticazione con chiave di sicurezza", "securityKeyRecommendation": "Considera di registrare un'altra chiave di sicurezza su un dispositivo diverso per assicurarti di non rimanere bloccato fuori dal tuo account.", "registering": "Registrazione in corso...", "securityKeyPrompt": "Verifica la tua identità usando la chiave di sicurezza. Assicurati che sia connessa e pronta.", "securityKeyBrowserNotSupported": "Il tuo browser non supporta le chiavi di sicurezza. Per favore, usa un browser moderno come Chrome, Firefox o Safari.", "securityKeyPermissionDenied": "Consenti accesso alla tua chiave di sicurezza per continuare ad accedere.", "securityKeyRemovedTooQuickly": "Mantieni la chiave di sicurezza connessa fino a quando il processo di accesso non è completato.", "securityKeyNotSupported": "La tua chiave di sicurezza potrebbe non essere compatibile. Prova un'altra chiave di sicurezza.", "securityKeyUnknownError": "Si è verificato un problema con la tua chiave di sicurezza. Riprova.", "twoFactorRequired": "È richiesta l'autenticazione a due fattori per registrare una chiave di sicurezza.", "twoFactor": "Autenticazione a Due Fattori", "twoFactorAuthentication": "Autenticazione A Due Fattori", "twoFactorDescription": "Questa organizzazione richiede l'autenticazione a due fattori.", "enableTwoFactor": "Abilita Autenticazione A Due Fattori", "organizationSecurityPolicy": "Politica Di Sicurezza Dell'Organizzazione", "organizationSecurityPolicyDescription": "Questa organizzazione ha requisiti di sicurezza che devono essere soddisfatti prima di poter accedere", "securityRequirements": "Requisiti Di Sicurezza", "allRequirementsMet": "Tutti i requisiti sono stati soddisfatti", "completeRequirementsToContinue": "Completa i requisiti qui sotto per continuare ad accedere a questa organizzazione", "youCanNowAccessOrganization": "Ora puoi accedere a questa organizzazione", "reauthenticationRequired": "Durata Sessione", "reauthenticationDescription": "Questa organizzazione richiede di accedere ogni {maxDays} giorni.", "reauthenticationDescriptionHours": "Questa organizzazione richiede di accedere ogni {maxHours} ore.", "reauthenticateNow": "Accedi Di Nuovo", "adminEnabled2FaOnYourAccount": "Il tuo amministratore ha abilitato l'autenticazione a due fattori per {email}. Completa il processo di configurazione per continuare.", "securityKeyAdd": "Aggiungi Chiave di Sicurezza", "securityKeyRegisterTitle": "Registra Nuova Chiave di Sicurezza", "securityKeyRegisterDescription": "Collega la tua chiave di sicurezza e inserisci un nome per identificarla", "securityKeyTwoFactorRequired": "Autenticazione a Due Fattori Richiesta", "securityKeyTwoFactorDescription": "Inserisci il codice di autenticazione a due fattori per registrare la chiave di sicurezza", "securityKeyTwoFactorRemoveDescription": "Inserisci il codice di autenticazione a due fattori per rimuovere la chiave di sicurezza", "securityKeyTwoFactorCode": "Codice a Due Fattori", "securityKeyRemoveTitle": "Rimuovi Chiave di Sicurezza", "securityKeyRemoveDescription": "Inserisci la tua password per rimuovere la chiave di sicurezza \"{name}\"", "securityKeyNoKeysRegistered": "Nessuna chiave di sicurezza registrata", "securityKeyNoKeysDescription": "Aggiungi una chiave di sicurezza per migliorare la sicurezza del tuo account", "createDomainRequired": "Dominio richiesto", "createDomainAddDnsRecords": "Aggiungi Record DNS", "createDomainAddDnsRecordsDescription": "Aggiungi i seguenti record DNS al tuo provider di domini per completare la configurazione.", "createDomainNsRecords": "Record NS", "createDomainRecord": "Record", "createDomainType": "Tipo:", "createDomainName": "Nome:", "createDomainValue": "Valore:", "createDomainCnameRecords": "Record CNAME", "createDomainARecords": "Record A", "createDomainRecordNumber": "Record {number}", "createDomainTxtRecords": "Record TXT", "createDomainSaveTheseRecords": "Salva Questi Record", "createDomainSaveTheseRecordsDescription": "Assicurati di salvare questi record DNS poiché non li vedrai più.", "createDomainDnsPropagation": "Propagazione DNS", "createDomainDnsPropagationDescription": "Le modifiche DNS possono richiedere del tempo per propagarsi in Internet. Questo può richiedere da pochi minuti a 48 ore, a seconda del tuo provider DNS e delle impostazioni TTL.", "resourcePortRequired": "Numero di porta richiesto per risorse non-HTTP", "resourcePortNotAllowed": "Il numero di porta non deve essere impostato per risorse HTTP", "billingPricingCalculatorLink": "Calcolatore di Prezzi", "billingYourPlan": "Il Tuo Piano", "billingViewOrModifyPlan": "Visualizza o modifica il tuo piano corrente", "billingViewPlanDetails": "Visualizza Dettagli Piano", "billingUsageAndLimits": "Utilizzo e limiti", "billingViewUsageAndLimits": "Visualizza i limiti del tuo piano e l'utilizzo corrente", "billingCurrentUsage": "Utilizzo Corrente", "billingMaximumLimits": "Limiti Massimi", "billingRemoteNodes": "Nodi Remoti", "billingUnlimited": "Illimitato", "billingPaidLicenseKeys": "Chiavi Di Licenza Pagate", "billingManageLicenseSubscription": "Gestisci il tuo abbonamento per le chiavi di licenza self-hosted a pagamento", "billingCurrentKeys": "Tasti Attuali", "billingModifyCurrentPlan": "Modifica Il Piano Corrente", "billingConfirmUpgrade": "Conferma Aggiornamento", "billingConfirmDowngrade": "Conferma Downgrade", "billingConfirmUpgradeDescription": "Stai per aggiornare il tuo piano. Controlla i nuovi limiti e prezzi qui sotto.", "billingConfirmDowngradeDescription": "Stai per effettuare il downgrade del tuo piano. Controlla i nuovi limiti e i prezzi qui sotto.", "billingPlanIncludes": "Piano Include", "billingProcessing": "Elaborazione...", "billingConfirmUpgradeButton": "Conferma Aggiornamento", "billingConfirmDowngradeButton": "Conferma Downgrade", "billingLimitViolationWarning": "Utilizzo Supera I Nuovi Limiti Del Piano", "billingLimitViolationDescription": "Il tuo utilizzo attuale supera i limiti di questo piano. Dopo il downgrading, tutte le azioni saranno disabilitate fino a ridurre l'utilizzo entro i nuovi limiti. Si prega di rivedere le caratteristiche qui sotto che sono attualmente oltre i limiti. Limiti di violazione:", "billingFeatureLossWarning": "Avviso Di Disponibilità Caratteristica", "billingFeatureLossDescription": "Con il downgrading, le funzioni non disponibili nel nuovo piano saranno disattivate automaticamente. Alcune impostazioni e configurazioni potrebbero andare perse. Controlla la matrice dei prezzi per capire quali funzioni non saranno più disponibili.", "billingUsageExceedsLimit": "L'utilizzo corrente ({current}) supera il limite ({limit})", "billingPastDueTitle": "Pagamento Scaduto", "billingPastDueDescription": "Il pagamento è scaduto. Si prega di aggiornare il metodo di pagamento per continuare a utilizzare le funzioni del piano corrente. Se non risolto, il tuo abbonamento verrà annullato e verrai ripristinato al livello gratuito.", "billingUnpaidTitle": "Abbonamento Non Pagato", "billingUnpaidDescription": "Il tuo abbonamento non è pagato e sei stato restituito al livello gratuito. Per favore aggiorna il metodo di pagamento per ripristinare l'abbonamento.", "billingIncompleteTitle": "Pagamento Incompleto", "billingIncompleteDescription": "Il pagamento è incompleto. Si prega di completare il processo di pagamento per attivare il tuo abbonamento.", "billingIncompleteExpiredTitle": "Pagamento Scaduto", "billingIncompleteExpiredDescription": "Il tuo pagamento non è mai stato completato ed è scaduto. Sei stato ripristinato al livello gratuito. Si prega di iscriversi nuovamente per ripristinare l'accesso alle funzionalità a pagamento.", "billingManageSubscription": "Gestisci il tuo abbonamento", "billingResolvePaymentIssue": "Si prega di risolvere il problema di pagamento prima di aggiornare o declassare", "signUpTerms": { "IAgreeToThe": "Accetto i", "termsOfService": "termini di servizio", "and": "e", "privacyPolicy": "informativa sulla privacy." }, "signUpMarketing": { "keepMeInTheLoop": "Tienimi in loop con notizie, aggiornamenti e nuove funzionalità via e-mail." }, "siteRequired": "Il sito è richiesto.", "olmTunnel": "Tunnel Olm", "olmTunnelDescription": "Usa Olm per la connettività client", "errorCreatingClient": "Errore nella creazione del client", "clientDefaultsNotFound": "Impostazioni predefinite del client non trovate", "createClient": "Crea Cliente", "createClientDescription": "Crea un nuovo client per accedere alle risorse private", "seeAllClients": "Vedi Tutti i Clienti", "clientInformation": "Informazioni sul Cliente", "clientNamePlaceholder": "Nome Cliente", "address": "Indirizzo", "subnetPlaceholder": "Sottorete", "addressDescription": "L'indirizzo interno del client. Deve rientrare nella sottorete dell'organizzazione.", "selectSites": "Seleziona siti", "sitesDescription": "Il cliente avrà connettività ai siti selezionati", "clientInstallOlm": "Installa Olm", "clientInstallOlmDescription": "Avvia Olm sul tuo sistema", "clientOlmCredentials": "Credenziali", "clientOlmCredentialsDescription": "Questo è il modo in cui il client si autenticerà con il server", "olmEndpoint": "Endpoint", "olmId": "ID", "olmSecretKey": "Segreto", "clientCredentialsSave": "Salva le credenziali", "clientCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.", "generalSettingsDescription": "Configura le impostazioni generali per questo cliente", "clientUpdated": "Cliente aggiornato", "clientUpdatedDescription": "Il cliente è stato aggiornato.", "clientUpdateFailed": "Impossibile aggiornare il cliente", "clientUpdateError": "Si è verificato un errore durante l'aggiornamento del cliente.", "sitesFetchFailed": "Impossibile recuperare i siti", "sitesFetchError": "Si è verificato un errore durante il recupero dei siti.", "olmErrorFetchReleases": "Si è verificato un errore durante il recupero delle versioni di Olm.", "olmErrorFetchLatest": "Si è verificato un errore durante il recupero dell'ultima versione di Olm.", "enterCidrRange": "Inserisci l'intervallo CIDR", "resourceEnableProxy": "Abilita Proxy Pubblico", "resourceEnableProxyDescription": "Abilita il proxy pubblico a questa risorsa. Consente l'accesso alla risorsa dall'esterno della rete tramite il cloud su una porta aperta. Richiede la configurazione di Traefik.", "externalProxyEnabled": "Proxy Esterno Abilitato", "addNewTarget": "Aggiungi Nuovo Target", "targetsList": "Elenco dei Target", "advancedMode": "Modalità Avanzata", "advancedSettings": "Impostazioni Avanzate", "targetErrorDuplicateTargetFound": "Target duplicato trovato", "healthCheckHealthy": "Sano", "healthCheckUnhealthy": "Non Sano", "healthCheckUnknown": "Sconosciuto", "healthCheck": "Controllo Salute", "configureHealthCheck": "Configura Controllo Salute", "configureHealthCheckDescription": "Imposta il monitoraggio della salute per {target}", "enableHealthChecks": "Abilita i Controlli di Salute", "enableHealthChecksDescription": "Monitorare lo stato di salute di questo obiettivo. Se necessario, è possibile monitorare un endpoint diverso da quello del bersaglio.", "healthScheme": "Metodo", "healthSelectScheme": "Seleziona Metodo", "healthCheckPortInvalid": "La porta di controllo dello stato di salute deve essere compresa tra 1 e 65535", "healthCheckPath": "Percorso", "healthHostname": "IP / Nome host", "healthPort": "Porta", "healthCheckPathDescription": "Percorso per verificare lo stato di salute.", "healthyIntervalSeconds": "Intervallo Sano (Sec)", "unhealthyIntervalSeconds": "Intervallo Non Sano (Sec)", "IntervalSeconds": "Intervallo Sano", "timeoutSeconds": "Timeout (sec)", "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 separati da virgole che l'utente può eseguire con sudo.", "sshCreateHomeDir": "Crea Cartella Home", "sshUnixGroups": "Gruppi Unix", "sshUnixGroupsDescription": "Gruppi Unix separati da virgole per 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.", "customHeaders": "Intestazioni Personalizzate", "customHeadersDescription": "Intestazioni nuova riga separate: Intestazione-Nome: valore", "headersValidationError": "Le intestazioni devono essere nel formato: Intestazione-Nome: valore.", "saveHealthCheck": "Salva Controllo Salute", "healthCheckSaved": "Controllo Salute Salvato", "healthCheckSavedDescription": "La configurazione del controllo salute è stata salvata con successo", "healthCheckError": "Errore Controllo Salute", "healthCheckErrorDescription": "Si è verificato un errore durante il salvataggio della configurazione del controllo salute.", "healthCheckPathRequired": "Il percorso del controllo salute è richiesto", "healthCheckMethodRequired": "Metodo HTTP richiesto", "healthCheckIntervalMin": "L'intervallo del controllo deve essere almeno di 5 secondi", "healthCheckTimeoutMin": "Il timeout deve essere di almeno 1 secondo", "healthCheckRetryMin": "I tentativi di riprova devono essere almeno 1", "httpMethod": "Metodo HTTP", "selectHttpMethod": "Seleziona metodo HTTP", "domainPickerSubdomainLabel": "Sottodominio", "domainPickerBaseDomainLabel": "Dominio Base", "domainPickerSearchDomains": "Cerca domini...", "domainPickerNoDomainsFound": "Nessun dominio trovato", "domainPickerLoadingDomains": "Caricamento domini...", "domainPickerSelectBaseDomain": "Seleziona dominio base...", "domainPickerNotAvailableForCname": "Non disponibile per i domini CNAME", "domainPickerEnterSubdomainOrLeaveBlank": "Inserisci un sottodominio o lascia vuoto per utilizzare il dominio base.", "domainPickerEnterSubdomainToSearch": "Inserisci un sottodominio per cercare e selezionare dai domini gratuiti disponibili.", "domainPickerFreeDomains": "Domini Gratuiti", "domainPickerSearchForAvailableDomains": "Cerca domini disponibili", "domainPickerNotWorkSelfHosted": "Nota: I domini forniti gratuitamente non sono disponibili per le istanze self-hosted al momento.", "resourceDomain": "Dominio", "resourceEditDomain": "Modifica Dominio", "siteName": "Nome del Sito", "proxyPort": "Porta", "resourcesTableProxyResources": "Pubblico", "resourcesTableClientResources": "Privato", "resourcesTableNoProxyResourcesFound": "Nessuna risorsa proxy trovata.", "resourcesTableNoInternalResourcesFound": "Nessuna risorsa interna trovata.", "resourcesTableDestination": "Destinazione", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Indirizzo Alias", "resourcesTableAliasAddressInfo": "Questo indirizzo fa parte della subnet di utilità dell'organizzazione. È usato per risolvere i record alias usando la risoluzione DNS interna.", "resourcesTableClients": "Client", "resourcesTableAndOnlyAccessibleInternally": "e sono accessibili solo internamente quando connessi con un client.", "resourcesTableNoTargets": "Nessun obiettivo", "resourcesTableHealthy": "Sano", "resourcesTableDegraded": "Degraded", "resourcesTableOffline": "Offline", "resourcesTableUnknown": "Sconosciuto", "resourcesTableNotMonitored": "Non monitorato", "editInternalResourceDialogEditClientResource": "Modifica Risorse Private", "editInternalResourceDialogUpdateResourceProperties": "Aggiorna la configurazione delle risorse e i controlli di accesso per {resourceName}", "editInternalResourceDialogResourceProperties": "Proprietà della Risorsa", "editInternalResourceDialogName": "Nome", "editInternalResourceDialogProtocol": "Protocollo", "editInternalResourceDialogSitePort": "Porta del Sito", "editInternalResourceDialogTargetConfiguration": "Configurazione Target", "editInternalResourceDialogCancel": "Annulla", "editInternalResourceDialogSaveResource": "Salva Risorsa", "editInternalResourceDialogSuccess": "Successo", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Risorsa interna aggiornata con successo", "editInternalResourceDialogError": "Errore", "editInternalResourceDialogFailedToUpdateInternalResource": "Impossibile aggiornare la risorsa interna", "editInternalResourceDialogNameRequired": "Il nome è obbligatorio", "editInternalResourceDialogNameMaxLength": "Il nome deve essere inferiore a 255 caratteri", "editInternalResourceDialogProxyPortMin": "La porta proxy deve essere almeno 1", "editInternalResourceDialogProxyPortMax": "La porta proxy deve essere inferiore a 65536", "editInternalResourceDialogInvalidIPAddressFormat": "Formato dell'indirizzo IP non valido", "editInternalResourceDialogDestinationPortMin": "La porta di destinazione deve essere almeno 1", "editInternalResourceDialogDestinationPortMax": "La porta di destinazione deve essere inferiore a 65536", "editInternalResourceDialogPortModeRequired": "Protocollo, porta proxy e porta di destinazione sono richiesti per la modalità porta", "editInternalResourceDialogMode": "Modalità", "editInternalResourceDialogModePort": "Porta", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Destinazione", "editInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.", "editInternalResourceDialogDestinationIPDescription": "L'indirizzo IP o hostname della risorsa nella rete del sito.", "editInternalResourceDialogDestinationCidrDescription": "La gamma CIDR della risorsa sulla rete del sito.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Un alias DNS interno opzionale per questa risorsa.", "createInternalResourceDialogNoSitesAvailable": "Nessun Sito Disponibile", "createInternalResourceDialogNoSitesAvailableDescription": "Devi avere almeno un sito Newt con una subnet configurata per creare risorse interne.", "createInternalResourceDialogClose": "Chiudi", "createInternalResourceDialogCreateClientResource": "Crea Risorsa Privata", "createInternalResourceDialogCreateClientResourceDescription": "Crea una nuova risorsa che sarà accessibile solo ai client connessi all'organizzazione", "createInternalResourceDialogResourceProperties": "Proprietà della Risorsa", "createInternalResourceDialogName": "Nome", "createInternalResourceDialogSite": "Sito", "selectSite": "Seleziona sito...", "noSitesFound": "Nessun sito trovato.", "createInternalResourceDialogProtocol": "Protocollo", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Porta del Sito", "createInternalResourceDialogSitePortDescription": "Usa questa porta per accedere alla risorsa nel sito quando sei connesso con un client.", "createInternalResourceDialogTargetConfiguration": "Configurazione Target", "createInternalResourceDialogDestinationIPDescription": "L'indirizzo IP o hostname della risorsa nella rete del sito.", "createInternalResourceDialogDestinationPortDescription": "La porta sull'IP di destinazione dove la risorsa è accessibile.", "createInternalResourceDialogCancel": "Annulla", "createInternalResourceDialogCreateResource": "Crea Risorsa", "createInternalResourceDialogSuccess": "Successo", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Risorsa interna creata con successo", "createInternalResourceDialogError": "Errore", "createInternalResourceDialogFailedToCreateInternalResource": "Impossibile creare la risorsa interna", "createInternalResourceDialogNameRequired": "Il nome è obbligatorio", "createInternalResourceDialogNameMaxLength": "Il nome non deve superare i 255 caratteri", "createInternalResourceDialogPleaseSelectSite": "Si prega di selezionare un sito", "createInternalResourceDialogProxyPortMin": "La porta proxy deve essere almeno 1", "createInternalResourceDialogProxyPortMax": "La porta proxy deve essere inferiore a 65536", "createInternalResourceDialogInvalidIPAddressFormat": "Formato dell'indirizzo IP non valido", "createInternalResourceDialogDestinationPortMin": "La porta di destinazione deve essere almeno 1", "createInternalResourceDialogDestinationPortMax": "La porta di destinazione deve essere inferiore a 65536", "createInternalResourceDialogPortModeRequired": "Protocollo, porta proxy e porta di destinazione sono richiesti per la modalità porta", "createInternalResourceDialogMode": "Modalità", "createInternalResourceDialogModePort": "Porta", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Destinazione", "createInternalResourceDialogDestinationHostDescription": "L'indirizzo IP o il nome host della risorsa nella rete del sito.", "createInternalResourceDialogDestinationCidrDescription": "La gamma CIDR della risorsa sulla rete del sito.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opzionale per questa risorsa.", "siteConfiguration": "Configurazione", "siteAcceptClientConnections": "Accetta Connessioni Client", "siteAcceptClientConnectionsDescription": "Consenti ai dispositivi utente e ai client di accedere alle risorse di questo sito. Questo può essere modificato in seguito.", "siteAddress": "Indirizzo Del Sito (Avanzato)", "siteAddressDescription": "L'indirizzo interno del sito. Deve rientrare nella sottorete dell'organizzazione.", "siteNameDescription": "Il nome visualizzato del sito che può essere modificato in seguito.", "autoLoginExternalIdp": "Accesso Automatico con IDP Esterno", "autoLoginExternalIdpDescription": "Reindirizzare immediatamente l'utente all'IDP esterno per l'autenticazione.", "selectIdp": "Seleziona IDP", "selectIdpPlaceholder": "Scegli un IDP...", "selectIdpRequired": "Si prega di selezionare un IDP quando l'accesso automatico è abilitato.", "autoLoginTitle": "Reindirizzamento", "autoLoginDescription": "Reindirizzandoti al provider di identità esterno per l'autenticazione.", "autoLoginProcessing": "Preparazione dell'autenticazione...", "autoLoginRedirecting": "Reindirizzamento al login...", "autoLoginError": "Errore di Accesso Automatico", "autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.", "autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione.", "remoteExitNodeManageRemoteExitNodes": "Nodi Remoti", "remoteExitNodeDescription": "Ospita in proprio i tuoi nodi server di relay e proxy remoti", "remoteExitNodes": "Nodi", "searchRemoteExitNodes": "Cerca nodi...", "remoteExitNodeAdd": "Aggiungi Nodo", "remoteExitNodeErrorDelete": "Errore nell'eliminare il nodo", "remoteExitNodeQuestionRemove": "Sei sicuro di voler rimuovere il nodo dall'organizzazione?", "remoteExitNodeMessageRemove": "Una volta rimosso, il nodo non sarà più accessibile.", "remoteExitNodeConfirmDelete": "Conferma Eliminazione Nodo", "remoteExitNodeDelete": "Elimina Nodo", "sidebarRemoteExitNodes": "Nodi Remoti", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Segreto", "remoteExitNodeCreate": { "title": "Crea Nodo Remoto", "description": "Crea un nuovo nodo server proxy e relay remoto ospitato in proprio", "viewAllButton": "Visualizza Tutti I Nodi", "strategy": { "title": "Strategia di Creazione", "description": "Seleziona come desideri creare il nodo remoto", "adopt": { "title": "Adotta Nodo", "description": "Scegli questo se hai già le credenziali per il nodo." }, "generate": { "title": "Genera Chiavi", "description": "Scegli questa opzione se vuoi generare nuove chiavi per il nodo." } }, "adopt": { "title": "Adotta Nodo Esistente", "description": "Inserisci le credenziali del nodo esistente che vuoi adottare", "nodeIdLabel": "ID Nodo", "nodeIdDescription": "L'ID del nodo esistente che si desidera adottare", "secretLabel": "Segreto", "secretDescription": "La chiave segreta del nodo esistente", "submitButton": "Adotta Nodo" }, "generate": { "title": "Credenziali Generate", "description": "Usa queste credenziali generate per configurare il nodo", "nodeIdTitle": "ID Nodo", "secretTitle": "Segreto", "saveCredentialsTitle": "Aggiungi Credenziali alla Configurazione", "saveCredentialsDescription": "Aggiungi queste credenziali al tuo file di configurazione del nodo self-hosted Pangolin per completare la connessione.", "submitButton": "Crea Nodo" }, "validation": { "adoptRequired": "L'ID del nodo e il segreto sono necessari quando si adotta un nodo esistente" }, "errors": { "loadDefaultsFailed": "Caricamento impostazioni predefinite fallito", "defaultsNotLoaded": "Impostazioni predefinite non caricate", "createFailed": "Impossibile creare il nodo" }, "success": { "created": "Nodo creato con successo" } }, "remoteExitNodeSelection": "Selezione Nodo", "remoteExitNodeSelectionDescription": "Seleziona un nodo per instradare il traffico per questo sito locale", "remoteExitNodeRequired": "Un nodo deve essere selezionato per i siti locali", "noRemoteExitNodesAvailable": "Nessun Nodo Disponibile", "noRemoteExitNodesAvailableDescription": "Non ci sono nodi disponibili per questa organizzazione. Crea un nodo prima per usare i siti locali.", "exitNode": "Nodo di Uscita", "country": "Paese", "rulesMatchCountry": "Attualmente basato sull'IP di origine", "managedSelfHosted": { "title": "Gestito Auto-Ospitato", "description": "Server Pangolin self-hosted più affidabile e a bassa manutenzione con campanelli e fischietti extra", "introTitle": "Managed Self-Hosted Pangolin", "introDescription": "è un'opzione di distribuzione progettata per le persone che vogliono la semplicità e l'affidabilità extra mantenendo i loro dati privati e self-hosted.", "introDetail": "Con questa opzione, esegui ancora il tuo nodo Pangolin — i tunnel, la terminazione SSL e il traffico rimangono tutti sul tuo server. La differenza è che la gestione e il monitoraggio sono gestiti attraverso il nostro cruscotto cloud, che sblocca una serie di vantaggi:", "benefitSimplerOperations": { "title": "Operazioni più semplici", "description": "Non è necessario eseguire il proprio server di posta o impostare un avviso complesso. Otterrai controlli di salute e avvisi di inattività fuori dalla casella." }, "benefitAutomaticUpdates": { "title": "Aggiornamenti automatici", "description": "Il cruscotto cloud si evolve rapidamente, in modo da ottenere nuove funzionalità e correzioni di bug senza dover tirare manualmente nuovi contenitori ogni volta." }, "benefitLessMaintenance": { "title": "Meno manutenzione", "description": "Nessuna migrazione di database, backup o infrastruttura extra da gestire. Gestiamo questo problema nel cloud." }, "benefitCloudFailover": { "title": "failover del cloud", "description": "Se il tuo nodo scende, i tuoi tunnel possono temporaneamente fallire nei nostri punti di presenza cloud fino a quando non lo riporti online." }, "benefitHighAvailability": { "title": "Alta disponibilità (PoPs)", "description": "Puoi anche allegare più nodi al tuo account per ridondanza e prestazioni migliori." }, "benefitFutureEnhancements": { "title": "Miglioramenti futuri", "description": "Stiamo pianificando di aggiungere più strumenti di analisi, allerta e gestione per rendere la tua distribuzione ancora più robusta." }, "docsAlert": { "text": "Scopri di più sull'opzione Managed Self-Hosted nella nostra", "documentation": "documentazione" }, "convertButton": "Converti questo nodo in auto-ospitato gestito" }, "internationaldomaindetected": "Dominio Internazionale Rilevato", "willbestoredas": "Verrà conservato come:", "roleMappingDescription": "Determinare come i ruoli sono assegnati agli utenti quando accedono quando è abilitata la fornitura automatica.", "selectRole": "Seleziona un ruolo", "roleMappingExpression": "Espressione", "selectRolePlaceholder": "Scegli un ruolo", "selectRoleDescription": "Seleziona un ruolo da assegnare a tutti gli utenti da questo provider di identità", "roleMappingExpressionDescription": "Inserire un'espressione JMESPath per estrarre le informazioni sul ruolo dal token ID", "idpTenantIdRequired": "L'ID dell'inquilino è obbligatorio", "invalidValue": "Valore non valido", "idpTypeLabel": "Tipo Provider Identità", "roleMappingExpressionPlaceholder": "es. contiene(gruppi, 'admin') && 'Admin' 'Membro'", "idpGoogleConfiguration": "Configurazione Google", "idpGoogleConfigurationDescription": "Configura le credenziali di Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Google OAuth2 Client Secret", "idpAzureConfiguration": "Configurazione Azure Entra ID", "idpAzureConfigurationDescription": "Configura le credenziali OAuth2 di Azure Entra ID", "idpTenantId": "ID Tenant", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "Azure tenant ID (trovato nella panoramica Azure Active Directory)", "idpAzureClientIdDescription": "Azure App Id Registrazione Client", "idpAzureClientSecretDescription": "Azure App Registrazione Client Segreto", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Configurazione Google", "idpAzureConfigurationTitle": "Configurazione Azure Entra ID", "idpTenantIdLabel": "ID Tenant", "idpAzureClientIdDescription2": "Azure App Id Registrazione Client", "idpAzureClientSecretDescription2": "Azure App Registrazione Client Segreto", "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sottorete", "subnetDescription": "La sottorete per la configurazione di rete di questa organizzazione.", "customDomain": "Dominio Personalizzato", "authPage": "Pagine di Autenticazione", "authPageDescription": "Imposta un dominio personalizzato per le pagine di autenticazione dell'organizzazione", "authPageDomain": "Dominio Pagina Auth", "authPageBranding": "Branding Personalizzato", "authPageBrandingDescription": "Configura il branding che appare sulle pagine di autenticazione per questa organizzazione", "authPageBrandingUpdated": "Branding della pagina di autenticazione aggiornato con successo", "authPageBrandingRemoved": "Branding della pagina di autenticazione rimosso con successo", "authPageBrandingRemoveTitle": "Rimuovere Branding Pagina di Autenticazione", "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)", "brandingOrgTitle": "Titolo per la Pagina di Autenticazione dell'Organizzazione", "brandingOrgDescription": "{orgName} sarà sostituito con il nome dell'organizzazione", "brandingOrgSubtitle": "Sottotitolo per la Pagina di Autenticazione dell'Organizzazione", "brandingResourceTitle": "Titolo per la Pagina di Autenticazione della Risorsa", "brandingResourceSubtitle": "Sottotitolo per la Pagina di Autenticazione della Risorsa", "brandingResourceDescription": "{resourceName} sarà sostituito con il nome dell'organizzazione", "saveAuthPageDomain": "Salva Dominio", "saveAuthPageBranding": "Salva Branding", "removeAuthPageBranding": "Rimuovi Branding", "noDomainSet": "Nessun dominio impostato", "changeDomain": "Cambia Dominio", "selectDomain": "Seleziona Dominio", "restartCertificate": "Riavvia Certificato", "editAuthPageDomain": "Modifica Dominio Pagina Auth", "setAuthPageDomain": "Imposta Dominio Pagina Autenticazione", "failedToFetchCertificate": "Recupero del certificato non riuscito", "failedToRestartCertificate": "Riavvio del certificato non riuscito", "addDomainToEnableCustomAuthPages": "Gli utenti potranno accedere alla pagina di accesso dell'organizzazione e completare l'autenticazione delle risorse utilizzando questo dominio.", "selectDomainForOrgAuthPage": "Seleziona un dominio per la pagina di autenticazione dell'organizzazione", "domainPickerProvidedDomain": "Dominio Fornito", "domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito", "domainPickerVerified": "Verificato", "domainPickerUnverified": "Non Verificato", "domainPickerInvalidSubdomainStructure": "Questo sottodominio contiene caratteri o struttura non validi. Sarà sanificato automaticamente quando si salva.", "domainPickerError": "Errore", "domainPickerErrorLoadDomains": "Impossibile caricare i domini dell'organizzazione", "domainPickerErrorCheckAvailability": "Impossibile verificare la disponibilità del dominio", "domainPickerInvalidSubdomain": "Sottodominio non valido", "domainPickerInvalidSubdomainRemoved": "L'input \"{sub}\" è stato rimosso perché non è valido.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" non può essere reso valido per {domain}.", "domainPickerSubdomainSanitized": "Sottodominio igienizzato", "domainPickerSubdomainCorrected": "\"{sub}\" è stato corretto in \"{sanitized}\"", "orgAuthSignInTitle": "Accedi all'Organizzazione", "orgAuthChooseIdpDescription": "Scegli il tuo provider di identità per continuare", "orgAuthNoIdpConfigured": "Questa organizzazione non ha nessun provider di identità configurato. Puoi accedere con la tua identità Pangolin.", "orgAuthSignInWithPangolin": "Accedi con Pangolino", "orgAuthSignInToOrg": "Accedi a un'organizzazione", "orgAuthSelectOrgTitle": "Accesso Organizzazione", "orgAuthSelectOrgDescription": "Inserisci l'ID dell'organizzazione per continuare", "orgAuthOrgIdPlaceholder": "la-tua-organizzazione", "orgAuthOrgIdHelp": "Inserisci l'identificatore univoco della tua organizzazione", "orgAuthSelectOrgHelp": "Dopo aver inserito l'ID dell'organizzazione, verrai indirizzato alla pagina di accesso dell'organizzazione dove puoi usare SSO o le credenziali dell'organizzazione.", "orgAuthRememberOrgId": "Ricorda questo ID organizzazione", "orgAuthBackToSignIn": "Torna alla modalità di accesso standard", "orgAuthNoAccount": "Non hai un account?", "subscriptionRequiredToUse": "Per utilizzare questa funzionalità è necessario un abbonamento.", "mustUpgradeToUse": "Devi aggiornare il tuo abbonamento per utilizzare questa funzionalità.", "subscriptionRequiredTierToUse": "Questa funzione richiede {tier} o superiore.", "upgradeToTierToUse": "Aggiorna ad {tier} o superiore per utilizzare questa funzionalità.", "subscriptionTierTier1": "Home", "subscriptionTierTier2": "Squadra", "subscriptionTierTier3": "Business", "subscriptionTierEnterprise": "Impresa", "idpDisabled": "I provider di identità sono disabilitati.", "orgAuthPageDisabled": "La pagina di autenticazione dell'organizzazione è disabilitata.", "domainRestartedDescription": "Verifica del dominio riavviata con successo", "resourceAddEntrypointsEditFile": "Modifica file: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Modifica file: docker-compose.yml", "emailVerificationRequired": "Verifica via email. Effettua nuovamente il login via {dashboardUrl}/auth/login completa questo passaggio. Quindi, torna qui.", "twoFactorSetupRequired": "È richiesta la configurazione di autenticazione a due fattori. Effettua nuovamente l'accesso tramite {dashboardUrl}/auth/login completa questo passaggio. Quindi, torna qui.", "additionalSecurityRequired": "Necessaria Sicurezza Aggiuntiva", "organizationRequiresAdditionalSteps": "Questa organizzazione richiede ulteriori passi di sicurezza prima di poter accedere alle risorse.", "completeTheseSteps": "Completa questi passaggi", "enableTwoFactorAuthentication": "Abilita autenticazione a due fattori", "completeSecuritySteps": "Passi Di Sicurezza Completa", "securitySettings": "Impostazioni Di Sicurezza", "dangerSection": "Zona Pericolosa", "dangerSectionDescription": "Elimina permanentemente tutti i dati associati a questa organizzazione", "securitySettingsDescription": "Configura i criteri di sicurezza per l'organizzazione", "requireTwoFactorForAllUsers": "Richiede l'autenticazione a due fattori per tutti gli utenti", "requireTwoFactorDescription": "Se abilitata, tutti gli utenti interni di questa organizzazione devono avere un'autenticazione a due fattori abilitata per accedere all'organizzazione.", "requireTwoFactorDisabledDescription": "Questa funzione richiede una licenza valida (Enterprise) o un abbonamento attivo (SaaS)", "requireTwoFactorCannotEnableDescription": "Devi abilitare l'autenticazione a due fattori per il tuo account prima di applicarla per tutti gli utenti", "maxSessionLength": "Lunghezza Massima Della Sessione", "maxSessionLengthDescription": "Imposta la durata massima per le sessioni utente. Dopo questo periodo, gli utenti dovranno autenticarsi.", "maxSessionLengthDisabledDescription": "Questa funzione richiede una licenza valida (Enterprise) o un abbonamento attivo (SaaS)", "selectSessionLength": "Seleziona lunghezza sessione", "unenforced": "Non Applicato", "1Hour": "1 ora", "3Hours": "3 ore", "6Hours": "6 ore", "12Hours": "12 ore", "1DaySession": "1 giorno", "3Days": "3 giorni", "7Days": "7 giorni", "14Days": "14 giorni", "30DaysSession": "30 giorni", "90DaysSession": "90 giorni", "180DaysSession": "180 giorni", "passwordExpiryDays": "Scadenza Password", "editPasswordExpiryDescription": "Imposta il numero di giorni prima che gli utenti debbano cambiare la password.", "selectPasswordExpiry": "Seleziona scadenza password", "30Days": "30 giorni", "1Day": "1 giorno", "60Days": "60 giorni", "90Days": "90 giorni", "180Days": "180 giorni", "1Year": "1 anno", "subscriptionBadge": "Abbonamento Richiesto", "securityPolicyChangeWarning": "Avviso Modifica Politica Di Sicurezza", "securityPolicyChangeDescription": "Si sta per modificare le impostazioni dei criteri di sicurezza. Dopo il salvataggio, potrebbe essere necessario autenticarsi nuovamente per conformarsi a questi aggiornamenti dei criteri. Tutti gli utenti che non sono conformi dovranno anche autenticarsi.", "securityPolicyChangeConfirmMessage": "Confermo", "securityPolicyChangeWarningText": "Questo influenzerà tutti gli utenti dell'organizzazione", "authPageErrorUpdateMessage": "Si è verificato un errore durante l'aggiornamento delle impostazioni della pagina di autenticazione", "authPageErrorUpdate": "Impossibile aggiornare la pagina di autenticazione", "authPageDomainUpdated": "Dominio della pagina di autenticazione aggiornato con successo", "healthCheckNotAvailable": "Locale", "rewritePath": "Riscrivi percorso", "rewritePathDescription": "Riscrivi eventualmente il percorso prima di inoltrarlo al target.", "continueToApplication": "Continua con l'applicazione", "checkingInvite": "Controllo Invito", "setResourceHeaderAuth": "setResourceHeaderAuth", "resourceHeaderAuthRemove": "Rimuovi Autenticazione Intestazione", "resourceHeaderAuthRemoveDescription": "Autenticazione intestazione rimossa con successo.", "resourceErrorHeaderAuthRemove": "Impossibile rimuovere l'autenticazione dell'intestazione", "resourceErrorHeaderAuthRemoveDescription": "Impossibile rimuovere l'autenticazione dell'intestazione per la risorsa.", "resourceHeaderAuthProtectionEnabled": "Autenticazione Intestazione Abilitata", "resourceHeaderAuthProtectionDisabled": "Autenticazione Intestazione Disabilitata", "headerAuthRemove": "Rimuovi Autenticazione Intestazione", "headerAuthAdd": "Aggiungi Autenticazione Intestazione", "resourceErrorHeaderAuthSetup": "Impossibile impostare l'autenticazione dell'intestazione", "resourceErrorHeaderAuthSetupDescription": "Impossibile impostare l'autenticazione dell'intestazione per la risorsa.", "resourceHeaderAuthSetup": "Autenticazione intestazione impostata con successo", "resourceHeaderAuthSetupDescription": "L'autenticazione dell'intestazione è stata impostata correttamente.", "resourceHeaderAuthSetupTitle": "Imposta Autenticazione Intestazione", "resourceHeaderAuthSetupTitleDescription": "Imposta le credenziali di autenticazione di base (nome utente e password) per proteggere questa risorsa con Autenticazione intestazione HTTP. Accedi usando il formato https://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Imposta Autenticazione Intestazione", "actionSetResourceHeaderAuth": "Imposta Autenticazione Intestazione", "enterpriseEdition": "Enterprise Edition", "unlicensed": "Senza Licenza", "beta": "Beta", "manageUserDevices": "Dispositivi Utente", "manageUserDevicesDescription": "Visualizza e gestisci i dispositivi che gli utenti utilizzano per connettersi privatamente alle risorse", "downloadClientBannerTitle": "Scarica il Client Pangolin", "downloadClientBannerDescription": "Scarica il client Pangolin per il tuo sistema per connetterti alla rete Pangolin e accedere alle risorse in modo privato.", "manageMachineClients": "Gestisci Client Machine", "manageMachineClientsDescription": "Creare e gestire client che server e sistemi utilizzano per connettersi privatamente alle risorse", "machineClientsBannerTitle": "Server e Sistemi Automatizzati", "machineClientsBannerDescription": "I client macchina sono destinati ai server e ai sistemi automatizzati che non sono associati a un utente specifico. Si autenticano con un ID e un segreto e possono funzionare con la CLI di Pangolin, la CLI di Olm o Olm come container.", "machineClientsBannerPangolinCLI": "CLI Pangolin", "machineClientsBannerOlmCLI": "CLI Olm", "machineClientsBannerOlmContainer": "Container Olm", "clientsTableUserClients": "Utente", "clientsTableMachineClients": "Macchina", "licenseTableValidUntil": "Valido Fino A", "saasLicenseKeysSettingsTitle": "Licenze Enterprise", "saasLicenseKeysSettingsDescription": "Genera e gestisci le chiavi di licenza Enterprise per le istanze di Pangolin self-hosted", "sidebarEnterpriseLicenses": "Licenze", "generateLicenseKey": "Genera Chiave Di Licenza", "generateLicenseKeyForm": { "validation": { "emailRequired": "Inserisci un indirizzo email valido", "useCaseTypeRequired": "Si prega di selezionare un tipo di caso di utilizzo", "firstNameRequired": "Il nome è obbligatorio", "lastNameRequired": "Il cognome è obbligatorio", "primaryUseRequired": "Descrivi il tuo uso primario", "jobTitleRequiredBusiness": "Il titolo di lavoro è richiesto per l'uso aziendale", "industryRequiredBusiness": "L'industria è richiesta per l'uso commerciale", "stateProvinceRegionRequired": "Stato/Provincia/Regione è richiesta", "postalZipCodeRequired": "Codice postale/CAP obbligatorio", "companyNameRequiredBusiness": "Il nome dell'azienda è richiesto per l'uso aziendale", "countryOfResidenceRequiredBusiness": "Paese di residenza è richiesto per uso professionale", "countryRequiredPersonal": "Il paese è richiesto per uso personale", "agreeToTermsRequired": "Devi accettare i termini", "complianceConfirmationRequired": "È necessario confermare la conformità alla licenza commerciale Fossorial" }, "useCaseOptions": { "personal": { "title": "Uso Personale", "description": "Per uso individuale, non commerciale, come l'apprendimento, progetti personali o sperimentazione." }, "business": { "title": "Uso Aziendale", "description": "Da utilizzare all'interno di organizzazioni, aziende o attività commerciali o generatrici di entrate." } }, "steps": { "emailLicenseType": { "title": "Email & Tipo Di Licenza", "description": "Inserisci la tua email e scegli il tipo di licenza" }, "personalInformation": { "title": "Informazioni Personali", "description": "Raccontaci di te" }, "contactInformation": { "title": "Informazioni Di Contatto", "description": "I tuoi dati di contatto" }, "termsGenerate": { "title": "Termini E Genera", "description": "Controlla e accetta i termini per generare la tua licenza" } }, "alerts": { "commercialUseDisclosure": { "title": "Trasparenza Di Utilizzo", "description": "Seleziona il livello di licenza che rispecchia accuratamente il tuo utilizzo previsto. La Licenza Personale consente l'uso gratuito del Software per le attività commerciali individuali, non commerciali o su piccola scala con entrate lorde annue inferiori a $100.000 USD. Qualsiasi uso oltre questi limiti — compreso l'uso all'interno di un'azienda, organizzazione, o altro ambiente generatore di entrate — richiede una licenza Enterprise valida e il pagamento della tassa di licenza applicabile. Tutti gli utenti, siano essi personali o aziendali, devono rispettare i termini di licenza commerciale Fossorial." }, "trialPeriodInformation": { "title": "Informazioni Periodo Di Prova", "description": "Questa chiave di licenza abilita le funzionalità Enterprise per un periodo di valutazione di 7 giorni. L'accesso continuo alle funzionalità a pagamento oltre il periodo di valutazione richiede l'attivazione con una licenza personale o Enterprise valida. Per la licenza Enterprise contatta sales@pangolin.net." } }, "form": { "useCaseQuestion": "Stai usando Pangolin per uso personale o di affari?", "firstName": "Nome", "lastName": "Cognome", "jobTitle": "Titolo Del Lavoro", "primaryUseQuestion": "Per che cosa hai in primo luogo intenzione di usare Pangolin?", "industryQuestion": "Qual è la sua industria?", "prospectiveUsersQuestion": "Quanti potenziali utenti si aspettano di avere?", "prospectiveSitesQuestion": "Quanti siti potenziali (gallerie) ci si aspetta di avere?", "companyName": "Nome della società", "countryOfResidence": "Paese di residenza", "stateProvinceRegion": "Stato / Provincia / Regione", "postalZipCode": "Codice Postale / Zip", "companyWebsite": "Sito web dell'azienda", "companyPhoneNumber": "Numero di telefono dell'azienda", "country": "Paese", "phoneNumberOptional": "Numero di telefono (facoltativo)", "complianceConfirmation": "Confermo che le informazioni che ho fornito sono accurate e che sono in conformità con la Fossorial Commercial License. La segnalazione di informazioni inesatte o l'uso errato del prodotto è una violazione della licenza e può portare alla revoca della chiave." }, "buttons": { "close": "Chiudi", "previous": "Precedente", "next": "Successivo", "generateLicenseKey": "Genera Chiave Di Licenza" }, "toasts": { "success": { "title": "Chiave di licenza generata con successo", "description": "La chiave di licenza è stata generata ed è pronta per l'uso." }, "error": { "title": "Impossibile generare la chiave di licenza", "description": "Si è verificato un errore nella generazione della chiave di licenza." } } }, "newPricingLicenseForm": { "title": "Ottieni una licenza", "description": "Scegli un piano e ci dica come intendi usare Pangolin.", "chooseTier": "Scegli il tuo piano", "viewPricingLink": "Vedi prezzi, funzionalità e limiti", "tiers": { "starter": { "title": "Avviatore", "description": "Caratteristiche aziendali, 25 utenti, 25 siti e supporto alla comunità." }, "scale": { "title": "Scala", "description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario." } }, "personalUseOnly": "Solo uso personale (licenza gratuita — nessun checkout)", "buttons": { "continueToCheckout": "Continua al Checkout" }, "toasts": { "checkoutError": { "title": "Errore di pagamento", "description": "Impossibile avviare il checkout. Per favore riprova." } } }, "priority": "Priorità", "priorityDescription": "I percorsi prioritari più alti sono valutati prima. Priorità = 100 significa ordinamento automatico (decidi di sistema). Usa un altro numero per applicare la priorità manuale.", "instanceName": "Nome Istanza", "pathMatchModalTitle": "Configura Corrispondenza Percorso", "pathMatchModalDescription": "Impostare come le richieste in arrivo devono essere abbinate in base al loro percorso.", "pathMatchType": "Tipo di Corrispondenza", "pathMatchPrefix": "Prefisso", "pathMatchExact": "Esatto", "pathMatchRegex": "Regex", "pathMatchValue": "Valore Percorso", "clear": "Pulisci", "saveChanges": "Salva Modifiche", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/path", "pathMatchPrefixHelp": "Esempio: /api corrisponde /api, /api/users etc.", "pathMatchExactHelp": "Esempio: /api corrisponde solo /api", "pathMatchRegexHelp": "Esempio: ^/api/.* corrisponde /api/anything", "pathRewriteModalTitle": "Configura La Riscrittura Percorso", "pathRewriteModalDescription": "Trasforma il percorso corrispondente prima di inoltrarlo al bersaglio.", "pathRewriteType": "Tipo Di Riscrittura", "pathRewritePrefixOption": "Prefisso - Sostituisci prefisso", "pathRewriteExactOption": "Esatto - Sostituisci l'intero percorso", "pathRewriteRegexOption": "Regex - Sostituzione modello", "pathRewriteStripPrefixOption": "Prefisso striscia - Rimuovi prefisso", "pathRewriteValue": "Riscrittura Valore", "pathRewriteRegexPlaceholder": "/new/$1", "pathRewriteDefaultPlaceholder": "/nuovo-percorso", "pathRewritePrefixHelp": "Sostituisci il prefisso abbinato con questo valore", "pathRewriteExactHelp": "Sostituisci l'intero percorso con questo valore quando il percorso corrisponde esattamente", "pathRewriteRegexHelp": "Usa gruppi di acquisizione come $1, $2 per la sostituzione", "pathRewriteStripPrefixHelp": "Lasciare vuoto per strisciare il prefisso o fornire un nuovo prefisso", "pathRewritePrefix": "Prefisso", "pathRewriteExact": "Esatto", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Striscia", "pathRewriteStripLabel": "striscia", "sidebarEnableEnterpriseLicense": "Abilita Licenza Enterprise", "cannotbeUndone": "Questo non può essere annullato.", "toConfirm": "per confermare.", "deleteClientQuestion": "Sei sicuro di voler rimuovere il client dal sito e dall'organizzazione?", "clientMessageRemove": "Una volta rimosso, il client non sarà più in grado di connettersi al sito.", "sidebarLogs": "Registri", "request": "Richiesta", "requests": "Richieste", "logs": "Registri", "logsSettingsDescription": "Monitora i log raccolti da questa organizzazione", "searchLogs": "Cerca registro...", "action": "Azione", "actor": "Attore", "timestamp": "Timestamp", "accessLogs": "Log Accesso", "exportCsv": "Esporta CSV", "exportError": "Errore sconosciuto durante l'esportazione del CSV", "exportCsvTooltip": "All'interno dell'intervallo di tempo", "actorId": "Id Attore", "allowedByRule": "Consentito dalla regola", "allowedNoAuth": "Non Consentito Auth", "validAccessToken": "Token Di Accesso Valido", "validHeaderAuth": "Valid header auth", "validPincode": "Valid Pincode", "validPassword": "Password Valida", "validEmail": "Valid email", "validSSO": "Valid SSO", "resourceBlocked": "Risorsa Bloccata", "droppedByRule": "Eliminato dalla regola", "noSessions": "Nessuna Sessione", "temporaryRequestToken": "Token Di Richiesta Temporaneo", "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Motivo", "requestLogs": "Log Richiesta", "requestAnalytics": "Richiedi Analisi", "host": "Host", "location": "Posizione", "actionLogs": "Log Azioni", "sidebarLogsRequest": "Log Richiesta", "sidebarLogsAccess": "Log Accesso", "sidebarLogsAction": "Log Azioni", "logRetention": "Ritenzione Registro", "logRetentionDescription": "Gestisci per quanto tempo i diversi tipi di log sono mantenuti per questa organizzazione o disabilitali", "requestLogsDescription": "Visualizza i registri di richiesta dettagliati per le risorse in questa organizzazione", "requestAnalyticsDescription": "Visualizza le analisi dettagliate della richiesta per le risorse in questa organizzazione", "logRetentionRequestLabel": "Richiedi Ritenzione Log", "logRetentionRequestDescription": "Per quanto tempo conservare i log delle richieste", "logRetentionAccessLabel": "Ritenzione Registro Accesso", "logRetentionAccessDescription": "Per quanto tempo conservare i log di accesso", "logRetentionActionLabel": "Ritenzione Registro Azioni", "logRetentionActionDescription": "Per quanto tempo conservare i log delle azioni", "logRetentionDisabled": "Disabilitato", "logRetention3Days": "3 giorni", "logRetention7Days": "7 giorni", "logRetention14Days": "14 giorni", "logRetention30Days": "30 giorni", "logRetention90Days": "90 giorni", "logRetentionForever": "Per Sempre", "logRetentionEndOfFollowingYear": "Fine dell'anno successivo", "actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione", "accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione", "licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza Enterprise Edition . Questa funzionalità è disponibile anche in Pangolin Cloud.", "ossEnterpriseEditionRequired": "L' Enterprise Edition è necessaria per utilizzare questa funzione. Questa funzionalità è disponibile anche in Pangolin Cloud.", "certResolver": "Risolutore Di Certificato", "certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.", "selectCertResolver": "Seleziona Risolutore Di Certificato", "enterCustomResolver": "Inserisci Risolutore Personalizzato", "preferWildcardCert": "Preferisci Certificato Wildcard", "unverified": "Non Verificato", "domainSetting": "Impostazioni Dominio", "domainSettingDescription": "Configura le impostazioni per il dominio", "preferWildcardCertDescription": "Tentativo di generare un certificato wildcard (richiede un resolver di certificato configurato correttamente).", "recordName": "Nome Record", "auto": "Automatico", "TTL": "TTL", "howToAddRecords": "Come aggiungere record", "dnsRecord": "Record DNS", "required": "Richiesto", "domainSettingsUpdated": "Impostazioni dominio aggiornate con successo", "orgOrDomainIdMissing": "Manca l'ID dell'organizzazione o del dominio", "loadingDNSRecords": "Caricamento record DNS...", "olmUpdateAvailableInfo": "È disponibile una versione aggiornata di Olm. Si prega di aggiornare all'ultima versione per la migliore esperienza.", "client": "Client", "proxyProtocol": "Impostazioni Protocollo Proxy", "proxyProtocolDescription": "Configurare il protocollo proxy per preservare gli indirizzi IP client per i servizi TCP.", "enableProxyProtocol": "Abilita Protocollo Proxy", "proxyProtocolInfo": "Conserva gli indirizzi IP del client per i backend TCP", "proxyProtocolVersion": "Versione Protocollo Proxy", "version1": " Versione 1 (Consigliato)", "version2": "Versione 2", "versionDescription": "La versione 1 è testuale e ampiamente supportata. La versione 2 è binaria e più efficiente, ma meno compatibile.", "warning": "Attenzione", "proxyProtocolWarning": "L'applicazione backend deve essere configurata per accettare le connessioni del protocollo proxy. Se il tuo backend non supporta il protocollo proxy, abilitarlo interromperà tutte le connessioni, quindi attivalo solo se sai cosa stai facendo. Assicurati di configurare il tuo backend per fidarti delle intestazioni del protocollo proxy da Traefik.", "restarting": "Riavvio...", "manual": "Manuale", "messageSupport": "Supporto Messaggio", "supportNotAvailableTitle": "Supporto Non Disponibile", "supportNotAvailableDescription": "Il supporto non è disponibile in questo momento. Puoi inviare un'email a support@pangolin.net.", "supportRequestSentTitle": "Richiesta Di Supporto Inviata", "supportRequestSentDescription": "Il tuo messaggio è stato inviato con successo.", "supportRequestFailedTitle": "Impossibile inviare la richiesta", "supportRequestFailedDescription": "Si è verificato un errore durante l'invio della richiesta di supporto.", "supportSubjectRequired": "L'oggetto è obbligatorio", "supportSubjectMaxLength": "L'oggetto deve contenere almeno 255 caratteri", "supportMessageRequired": "Il messaggio è obbligatorio", "supportReplyTo": "Rispondi A", "supportSubject": "Oggetto", "supportSubjectPlaceholder": "Inserisci oggetto", "supportMessage": "Messaggio", "supportMessagePlaceholder": "Inserisci il tuo messaggio", "supportSending": "Invio...", "supportSend": "Invia", "supportMessageSent": "Messaggio Inviato!", "supportWillContact": "Saremo in contatto a breve!", "selectLogRetention": "Seleziona ritenzione log", "terms": "Termini", "privacy": "Privacy", "security": "Sicurezza", "docs": "Documenti", "deviceActivation": "Attivazione dispositivo", "deviceCodeInvalidFormat": "Il codice deve contenere 9 caratteri (es. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Codice non valido o scaduto", "deviceCodeVerifyFailed": "Impossibile verificare il codice del dispositivo", "deviceCodeValidating": "Convalida codice dispositivo...", "deviceCodeVerifying": "Verifica autorizzazione dispositivo...", "signedInAs": "Accesso come", "deviceCodeEnterPrompt": "Inserisci il codice visualizzato sul dispositivo", "continue": "Continua", "deviceUnknownLocation": "Posizione sconosciuta", "deviceAuthorizationRequested": "Questa autorizzazione è stata richiesta ad {location} su {date}. Assicurati di fidarti di questo dispositivo in quanto avrà accesso all'account.", "deviceLabel": "Dispositivo: {deviceName}", "deviceWantsAccess": "vuole accedere al tuo account", "deviceExistingAccess": "Accesso esistente:", "deviceFullAccess": "Accesso completo al tuo account", "deviceOrganizationsAccess": "Accesso a tutte le organizzazioni a cui il tuo account ha accesso", "deviceAuthorize": "Autorizza {applicationName}", "deviceConnected": "Dispositivo Connesso!", "deviceAuthorizedMessage": "Il dispositivo è autorizzato ad accedere al tuo account. Ritorna all'applicazione client.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "Visualizza Dispositivi", "viewDevicesDescription": "Gestisci i tuoi dispositivi connessi", "noDevices": "Nessun dispositivo trovato", "dateCreated": "Data Di Creazione", "unnamedDevice": "Dispositivo Senza Nome", "deviceQuestionRemove": "Sei sicuro di voler eliminare questo dispositivo?", "deviceMessageRemove": "Questa azione non può essere annullata.", "deviceDeleteConfirm": "Elimina Dispositivo", "deleteDevice": "Elimina Dispositivo", "errorLoadingDevices": "Errore nel caricamento dei dispositivi", "failedToLoadDevices": "Impossibile caricare i dispositivi", "deviceDeleted": "Dispositivo eliminato", "deviceDeletedDescription": "Il dispositivo è stato eliminato con successo.", "errorDeletingDevice": "Errore nell'eliminare il dispositivo", "failedToDeleteDevice": "Impossibile eliminare il dispositivo", "showColumns": "Mostra Colonne", "hideColumns": "Nascondi Colonne", "columnVisibility": "Visibilità Colonna", "toggleColumn": "Attiva/disattiva colonna {columnName}", "allColumns": "Tutte Le Colonne", "defaultColumns": "Colonne Predefinite", "customizeView": "Personalizza Vista", "viewOptions": "Opzioni Visualizzazione", "selectAll": "Seleziona Tutto", "selectNone": "Seleziona Nessuno", "selectedResources": "Risorse Selezionate", "enableSelected": "Abilita Selezionati", "disableSelected": "Disabilita Selezionati", "checkSelectedStatus": "Controlla lo stato dei selezionati", "clients": "Client", "accessClientSelect": "Seleziona client macchina", "resourceClientDescription": "Clienti di macchine che possono accedere a questa risorsa", "regenerate": "Rigenera", "credentials": "Credenziali", "savecredentials": "Salva Credenziali", "regenerateCredentialsButton": "Rigenera Credenziali", "regenerateCredentials": "Rigenera Credenziali", "generatedcredentials": "Credenziali Generate", "copyandsavethesecredentials": "Copia e salva queste credenziali", "copyandsavethesecredentialsdescription": "Queste credenziali non verranno mostrate di nuovo dopo aver lasciato questa pagina. Salvarle in modo sicuro ora.", "credentialsSaved": "Credenziali Salvate", "credentialsSavedDescription": "Le credenziali sono state rigenerate e salvate con successo.", "credentialsSaveError": "Errore Di Salvataggio Credenziali", "credentialsSaveErrorDescription": "Errore durante la rigenerazione e il salvataggio delle credenziali.", "regenerateCredentialsWarning": "Rigenerare le credenziali invaliderà quelle precedenti e causerà una disconnessione. Assicurarsi di aggiornare le configurazioni che utilizzano queste credenziali.", "confirm": "Conferma", "regenerateCredentialsConfirmation": "Sei sicuro di voler rigenerare le credenziali?", "endpoint": "Endpoint", "Id": "Id", "SecretKey": "Chiave Segreta", "niceId": "Simpatico ID", "niceIdUpdated": "Nice ID Aggiornato", "niceIdUpdatedSuccessfully": "Nizza Id Aggiornato Con Successo", "niceIdUpdateError": "Errore nell'aggiornare Nice ID", "niceIdUpdateErrorDescription": "Si è verificato un errore durante l'aggiornamento del Nice ID.", "niceIdCannotBeEmpty": "Il Nice ID non può essere vuoto", "enterIdentifier": "Inserisci identificatore", "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Non tu? Usa un account diverso.", "deviceLoginDeviceRequestingAccessToAccount": "Un dispositivo sta richiedendo l'accesso a questo account.", "loginSelectAuthenticationMethod": "Selezionare un metodo di autenticazione per continuare.", "noData": "Nessun Dato", "machineClients": "Machine Clients", "install": "Installa", "run": "Esegui", "clientNameDescription": "Il nome visualizzato del client che può essere modificato in seguito.", "clientAddress": "Indirizzo Client (Avanzato)", "setupFailedToFetchSubnet": "Recupero della sottorete predefinita non riuscito", "setupSubnetAdvanced": "Subnet (avanzato)", "setupSubnetDescription": "La subnet per la rete interna di questa organizzazione.", "setupUtilitySubnet": "Sottorete di Utilità (Avanzata)", "setupUtilitySubnetDescription": "La sottorete per gli indirizzi alias e il server DNS di questa organizzazione.", "siteRegenerateAndDisconnect": "Rigenera e disconnetti", "siteRegenerateAndDisconnectConfirmation": "Sei sicuro di voler rigenerare le credenziali e disconnettere questo sito?", "siteRegenerateAndDisconnectWarning": "Questo rigenererà le credenziali e disconnetterà immediatamente il sito. Il sito dovrà essere riavviato con le nuove credenziali.", "siteRegenerateCredentialsConfirmation": "Sei sicuro di voler rigenerare le credenziali per questo sito?", "siteRegenerateCredentialsWarning": "Questo rigenererà le credenziali. Il sito rimarrà connesso finché non lo riavvierai manualmente e userai le nuove credenziali.", "clientRegenerateAndDisconnect": "Rigenera e disconnetti", "clientRegenerateAndDisconnectConfirmation": "Sei sicuro di voler rigenerare le credenziali e disconnettere questo client?", "clientRegenerateAndDisconnectWarning": "Questo rigenererà le credenziali e disconnetterà immediatamente il client. Il client dovrà essere riavviato con le nuove credenziali.", "clientRegenerateCredentialsConfirmation": "Sei sicuro di voler rigenerare le credenziali per questo client?", "clientRegenerateCredentialsWarning": "Questo rigenererà le credenziali. Il client rimarrà connesso fino a quando non lo riavvierai manualmente e userai le nuove credenziali.", "remoteExitNodeRegenerateAndDisconnect": "Rigenera e disconnetti", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Sei sicuro di voler rigenerare le credenziali e disconnettere questo nodo di uscita remoto?", "remoteExitNodeRegenerateAndDisconnectWarning": "Questo rigenererà le credenziali e disconnetterà immediatamente il nodo di uscita remoto. Il nodo di uscita remoto dovrà essere riavviato con le nuove credenziali.", "remoteExitNodeRegenerateCredentialsConfirmation": "Sei sicuro di voler rigenerare le credenziali per questo nodo di uscita remoto?", "remoteExitNodeRegenerateCredentialsWarning": "Questo rigenererà le credenziali. Il nodo di uscita remoto rimarrà connesso finché non lo riavvierai manualmente e userai le nuove credenziali.", "agent": "Agente", "personalUseOnly": "Solo per uso personale", "loginPageLicenseWatermark": "Questa istanza è concessa in licenza solo per uso personale.", "instanceIsUnlicensed": "Questa istanza non è concessa in licenza.", "portRestrictions": "Restrizioni sulle porte", "allPorts": "Tutti", "custom": "Personalizzato", "allPortsAllowed": "Tutte le porte consentite", "allPortsBlocked": "Tutte le porte bloccate", "tcpPortsDescription": "Specifica quali porte TCP sono consentite per questa risorsa. Usa '*' per tutte le porte, lascia vuoto per bloccare tutto o inserisci un elenco di porte e intervalli separato da virgole (ad es. 80,443,8000-9000).", "udpPortsDescription": "Specifica quali porte UDP sono consentite per questa risorsa. Usa '*' per tutte le porte, lascia vuoto per bloccare tutto o inserisci un elenco di porte e intervalli separato da virgole (ad es. 53,123,500-600).", "organizationLoginPageTitle": "Pagina di Accesso dell'Organizzazione", "organizationLoginPageDescription": "Personalizza la pagina di accesso per questa organizzazione", "resourceLoginPageTitle": "Pagina di Accesso delle Risorse", "resourceLoginPageDescription": "Personalizza la pagina di accesso per risorse individuali", "enterConfirmation": "Inserisci conferma", "blueprintViewDetails": "Dettagli", "defaultIdentityProvider": "Provider di Identità Predefinito", "defaultIdentityProviderDescription": "Quando viene selezionato un provider di identità predefinito, l'utente verrà automaticamente reindirizzato al provider per l'autenticazione.", "editInternalResourceDialogNetworkSettings": "Impostazioni di Rete", "editInternalResourceDialogAccessPolicy": "Politica di Accesso", "editInternalResourceDialogAddRoles": "Aggiungi Ruoli", "editInternalResourceDialogAddUsers": "Aggiungi Utenti", "editInternalResourceDialogAddClients": "Aggiungi Clienti", "editInternalResourceDialogDestinationLabel": "Destinazione", "editInternalResourceDialogDestinationDescription": "Specifica l'indirizzo di destinazione per la risorsa interna. Può essere un hostname, indirizzo IP o un intervallo CIDR a seconda della modalità selezionata. Opzionalmente imposta un alias DNS interno per una più facile identificazione.", "editInternalResourceDialogPortRestrictionsDescription": "Limita l'accesso a porte TCP/UDP specifiche o consenti/blocca tutte le porte.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Controllo Accesso", "editInternalResourceDialogAccessControlDescription": "Controlla quali ruoli, utenti e client macchina hanno accesso a questa risorsa quando connessi. Gli amministratori hanno sempre accesso.", "editInternalResourceDialogPortRangeValidationError": "Il range delle porte deve essere \"*\" per tutte le porte, o un elenco di porte e intervalli separato da virgole (ad es. \"80,443,8000-9000\"). Le porte devono essere tra 1 e 65535.", "internalResourceAuthDaemonStrategy": "Posizione Demone Autenticazione SSH", "internalResourceAuthDaemonStrategyDescription": "Scegli dove funziona il demone di autenticazione SSH: sul sito (Newt) o su un host remoto.", "internalResourceAuthDaemonDescription": "Il demone di autenticazione SSH gestisce la firma della chiave SSH e l'autenticazione PAM per questa risorsa. Scegli se viene eseguito sul sito (Newt) o su un host remoto separato. Vedi la documentazione per ulteriori informazioni.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Seleziona Strategia", "internalResourceAuthDaemonStrategyLabel": "Posizione", "internalResourceAuthDaemonSite": "Sul Sito", "internalResourceAuthDaemonSiteDescription": "Il demone Auth viene eseguito sul sito (Nuovo).", "internalResourceAuthDaemonRemote": "Host Remoto", "internalResourceAuthDaemonRemoteDescription": "Il demone di autenticazione viene eseguito su un host che non è il sito.", "internalResourceAuthDaemonPort": "Porta Demone (facoltativa)", "orgAuthWhatsThis": "Dove posso trovare l'ID della mia organizzazione?", "learnMore": "Scopri di più", "backToHome": "Torna alla home", "needToSignInToOrg": "Hai bisogno di utilizzare il provider di identità della tua organizzazione?", "maintenanceMode": "Modalità di Manutenzione", "maintenanceModeDescription": "Visualizza una pagina di manutenzione ai visitatori", "maintenanceModeType": "Tipo di Modalità di Manutenzione", "showMaintenancePage": "Mostra una pagina di manutenzione ai visitatori", "enableMaintenanceMode": "Abilita Modalità di Manutenzione", "automatic": "Automatico", "automaticModeDescription": "Mostra pagina di manutenzione solo quando tutti i target del backend sono inattivi o non in salute. La tua risorsa continua a funzionare normalmente finché almeno un target è in salute.", "forced": "Forzato", "forcedModeDescription": "Mostra sempre la pagina di manutenzione indipendentemente dalla salute del backend. Usa questo per la manutenzione programmata quando vuoi impedire tutto l'accesso.", "warning:": "Avviso:", "forcedeModeWarning": "Tutto il traffico verrà indirizzato alla pagina di manutenzione. Le risorse del tuo backend non riceveranno richieste.", "pageTitle": "Titolo Pagina", "pageTitleDescription": "L'intestazione principale visualizzata sulla pagina di manutenzione", "maintenancePageMessage": "Messaggio di Manutenzione", "maintenancePageMessagePlaceholder": "Torneremo presto! Il nostro sito è attualmente in manutenzione programmata.", "maintenancePageMessageDescription": "Messaggio dettagliato che spiega la manutenzione", "maintenancePageTimeTitle": "Tempo di Completamento Stimato (Opzionale)", "maintenanceTime": "es. 2 ore, 1 novembre alle 17:00", "maintenanceEstimatedTimeDescription": "Quando prevedi che la manutenzione sarà completata", "editDomain": "Modifica Dominio", "editDomainDescription": "Seleziona un dominio per la tua risorsa", "maintenanceModeDisabledTooltip": "Questa funzione richiede una licenza valida per essere abilitata.", "maintenanceScreenTitle": "Servizio Temporaneamente Non Disponibile", "maintenanceScreenMessage": "Stiamo attualmente riscontrando difficoltà tecniche. Si prega di ricontrollare a breve.", "maintenanceScreenEstimatedCompletion": "Completamento Stimato:", "createInternalResourceDialogDestinationRequired": "Destinazione richiesta", "available": "Disponibile", "archived": "Archiviato", "noArchivedDevices": "Nessun dispositivo archiviato trovato", "deviceArchived": "Dispositivo archiviato", "deviceArchivedDescription": "Il dispositivo è stato archiviato con successo.", "errorArchivingDevice": "Errore nell'archiviazione del dispositivo", "failedToArchiveDevice": "Impossibile archiviare il dispositivo", "deviceQuestionArchive": "È sicuro di voler archiviare questo dispositivo?", "deviceMessageArchive": "Il dispositivo verrà archiviato e rimosso dalla lista dei dispositivi attivi.", "deviceArchiveConfirm": "Archivia Dispositivo", "archiveDevice": "Archivia Dispositivo", "archive": "Archivio", "deviceUnarchived": "Dispositivo non archiviato", "deviceUnarchivedDescription": "Il dispositivo è stato disarchiviato con successo.", "errorUnarchivingDevice": "Errore nel disarchiviare il dispositivo", "failedToUnarchiveDevice": "Disarchiviazione del dispositivo non riuscita", "unarchive": "Disarchivia", "archiveClient": "Archivia Client", "archiveClientQuestion": "È sicuro di voler archiviare questo client?", "archiveClientMessage": "Il client verrà archiviato e rimosso dalla lista dei client attivi.", "archiveClientConfirm": "Archivia Client", "blockClient": "Blocca Client", "blockClientQuestion": "Sei sicuro di voler bloccare questo client?", "blockClientMessage": "Il dispositivo sarà forzato a disconnettersi se attualmente connesso. Puoi sbloccare il dispositivo più tardi.", "blockClientConfirm": "Blocca Client", "active": "Attivo", "usernameOrEmail": "Nome utente o Email", "selectYourOrganization": "Seleziona la tua organizzazione", "signInTo": "Accedi a", "signInWithPassword": "Continua con la password", "noAuthMethodsAvailable": "Nessun metodo di autenticazione disponibile per questa organizzazione.", "enterPassword": "Inserisci la tua password", "enterMfaCode": "Inserisci il codice dalla tua app di autenticazione", "securityKeyRequired": "Utilizza la tua chiave di sicurezza per accedere.", "needToUseAnotherAccount": "Hai bisogno di utilizzare un account diverso?", "loginLegalDisclaimer": "Facendo clic sui pulsanti qui sotto, si riconosce di aver letto, capire, e accettare i Termini di Servizio e Privacy Policy.", "termsOfService": "Termini di servizio", "privacyPolicy": "Politica Sulla Privacy", "userNotFoundWithUsername": "Nessun utente trovato con questo nome utente.", "verify": "Verifica", "signIn": "Accedi", "forgotPassword": "Password dimenticata?", "orgSignInTip": "Se hai effettuato l'accesso prima, puoi inserire il tuo nome utente o email qui sopra per autenticarti con il provider di identità della tua organizzazione. È più facile!", "continueAnyway": "Continua comunque", "dontShowAgain": "Non mostrare più", "orgSignInNotice": "Lo sapevate?", "signupOrgNotice": "Cercando di accedere?", "signupOrgTip": "Stai cercando di accedere tramite il provider di identità della tua organizzazione?", "signupOrgLink": "Accedi o registrati con la tua organizzazione", "verifyEmailLogInWithDifferentAccount": "Usa un account diverso", "logIn": "Log In", "deviceInformation": "Informazioni Sul Dispositivo", "deviceInformationDescription": "Informazioni sul dispositivo e sull'agente", "deviceSecurity": "Sicurezza Del Dispositivo", "deviceSecurityDescription": "Informazioni postura sicurezza dispositivo", "platform": "Piattaforma", "macosVersion": "versione macOS", "windowsVersion": "Versione Windows", "iosVersion": "Versione iOS", "androidVersion": "Versione Android", "osVersion": "Versione OS", "kernelVersion": "Versione Del Kernel", "deviceModel": "Modello Di Dispositivo", "serialNumber": "Numero D'Ordine", "hostname": "Hostname", "firstSeen": "Prima Visto", "lastSeen": "Visto L'Ultima", "biometricsEnabled": "Biometria Abilitata", "diskEncrypted": "Cifratura Del Disco", "firewallEnabled": "Firewall Abilitato", "autoUpdatesEnabled": "Aggiornamenti Automatici Abilitati", "tpmAvailable": "TPM Disponibile", "windowsAntivirusEnabled": "Antivirus Abilitato", "macosSipEnabled": "Protezione Dell'Integrità Del Sistema (Sip)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Modo Furtivo Del Firewall", "linuxAppArmorEnabled": "AppArmor", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "Visualizza informazioni e impostazioni del dispositivo", "devicePendingApprovalDescription": "Questo dispositivo è in attesa di approvazione", "deviceBlockedDescription": "Questo dispositivo è attualmente bloccato. Non sarà in grado di connettersi a nessuna risorsa a meno che non sia sbloccato.", "unblockClient": "Sblocca Client", "unblockClientDescription": "Il dispositivo è stato sbloccato", "unarchiveClient": "Annulla Archiviazione Client", "unarchiveClientDescription": "Il dispositivo è stato disarchiviato", "block": "Blocca", "unblock": "Sblocca", "deviceActions": "Azioni Dispositivo", "deviceActionsDescription": "Gestisci lo stato del dispositivo e l'accesso", "devicePendingApprovalBannerDescription": "Questo dispositivo è in attesa di approvazione. Non sarà in grado di connettersi alle risorse fino all'approvazione.", "connected": "Connesso", "disconnected": "Disconnesso", "approvalsEmptyStateTitle": "Approvazioni Dispositivo Non Abilitato", "approvalsEmptyStateDescription": "Abilita le approvazioni del dispositivo per i ruoli per richiedere l'approvazione dell'amministratore prima che gli utenti possano collegare nuovi dispositivi.", "approvalsEmptyStateStep1Title": "Vai ai ruoli", "approvalsEmptyStateStep1Description": "Vai alle impostazioni dei ruoli della tua organizzazione per configurare le approvazioni del dispositivo.", "approvalsEmptyStateStep2Title": "Abilita Approvazioni Dispositivo", "approvalsEmptyStateStep2Description": "Modifica un ruolo e abilita l'opzione 'Richiedi l'approvazione del dispositivo'. Gli utenti con questo ruolo avranno bisogno dell'approvazione dell'amministratore per i nuovi dispositivi.", "approvalsEmptyStatePreviewDescription": "Anteprima: quando abilitato, le richieste di dispositivo in attesa appariranno qui per la revisione", "approvalsEmptyStateButtonText": "Gestisci Ruoli" } ================================================ FILE: messages/ko-KR.json ================================================ { "setupCreate": "조직, 사이트 및 리소스를 생성합니다.", "headerAuthCompatibilityInfo": "인증 토큰이 없을 때 401 Unauthorized 응답을 강제하도록 설정합니다. 서버 챌린지 없이 자격 증명을 제공하지 않는 브라우저나 특정 HTTP 라이브러리에 필요합니다.", "headerAuthCompatibility": "확장된 호환성", "setupNewOrg": "새 조직", "setupCreateOrg": "조직 생성", "setupCreateResources": "리소스 생성", "setupOrgName": "조직 이름", "orgDisplayName": "이것은 조직의 표시 이름입니다.", "orgId": "조직 ID", "setupIdentifierMessage": "이것은 조직의 고유 식별자입니다.", "setupErrorIdentifier": "조직 ID가 이미 사용 중입니다. 다른 것을 선택해 주세요.", "componentsErrorNoMemberCreate": "현재 어떤 조직의 구성원도 아닙니다. 시작하려면 조직을 생성하세요.", "componentsErrorNoMember": "현재 어떤 조직의 구성원도 아닙니다.", "welcome": "판골린에 오신 것을 환영합니다.", "welcomeTo": "환영합니다", "componentsCreateOrg": "조직 생성", "componentsMember": "당신은 {count, plural, =0 {조직이 없습니다} one {하나의 조직} other {# 개의 조직}}의 구성원입니다.", "componentsInvalidKey": "유효하지 않거나 만료된 라이센스 키가 감지되었습니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", "dismiss": "해제", "subscriptionViolationMessage": "현재 계획의 한계를 초과했습니다. 사이트, 사용자 또는 기타 리소스를 제거하여 계획 내에 머물도록 해결하세요.", "subscriptionViolationViewBilling": "청구 보기", "componentsLicenseViolation": "라이센스 위반: 이 서버는 {usedSites} 사이트를 사용하고 있으며, 이는 {maxSites} 사이트의 라이센스 한도를 초과합니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", "componentsSupporterMessage": "{tier}로 판골린을 지원해 주셔서 감사합니다!", "inviteErrorNotValid": "죄송하지만, 접근하려는 초대가 수락되지 않았거나 더 이상 유효하지 않은 것 같습니다.", "inviteErrorUser": "죄송하지만, 접근하려는 초대가 이 사용자에게 해당되지 않는 것 같습니다.", "inviteLoginUser": "올바른 사용자로 로그인했는지 확인하십시오.", "inviteErrorNoUser": "죄송하지만, 접근하려는 초대가 존재하지 않는 사용자에 대한 것인 것 같습니다.", "inviteCreateUser": "먼저 계정을 생성해 주세요.", "goHome": "홈으로 가기", "inviteLogInOtherUser": "다른 사용자로 로그인", "createAnAccount": "계정 만들기", "inviteNotAccepted": "초대가 수락되지 않음", "authCreateAccount": "시작하려면 계정을 생성하세요.", "authNoAccount": "계정이 없으신가요?", "email": "이메일", "password": "비밀번호", "confirmPassword": "비밀번호 확인", "createAccount": "계정 생성", "viewSettings": "설정 보기", "delete": "삭제", "name": "이름", "online": "온라인", "offline": "오프라인", "site": "사이트", "dataIn": "데이터 입력", "dataOut": "데이터 출력", "connectionType": "연결 유형", "tunnelType": "터널 유형", "local": "로컬", "edit": "편집", "siteConfirmDelete": "사이트 삭제 확인", "siteDelete": "사이트 삭제", "siteMessageRemove": "삭제되면 사이트에 더 이상 액세스할 수 없습니다. 사이트와 연결된 모든 대상도 삭제됩니다.", "siteQuestionRemove": "조직에서 사이트를 제거하시겠습니까?", "siteManageSites": "사이트 관리", "siteDescription": "프라이빗 네트워크로의 연결을 활성화하려면 사이트를 생성하고 관리하세요.", "sitesBannerTitle": "모든 네트워크 연결", "sitesBannerDescription": "사이트는 원격 네트워크와의 연결로 Pangolin이 어디서나 사용자에게 공공 및 개인 리소스에 대한 접근을 제공할 수 있게 해 줍니다. 연결을 설정하려면 바이너리 또는 컨테이너로 실행할 수 있는 어디서든 사이트 네트워크 커넥터(Newt)를 설치하세요.", "sitesBannerButtonText": "사이트 설치", "approvalsBannerTitle": "장치 접근 승인 또는 거부", "approvalsBannerDescription": "사용자의 장치 접근 요청을 검토하고 승인하거나 거부하세요. 장치 승인 요구 시, 관리자의 승인이 필요합니다.", "approvalsBannerButtonText": "자세히 알아보기", "siteCreate": "사이트 생성", "siteCreateDescription2": "아래 단계를 따라 새 사이트를 생성하고 연결하십시오", "siteCreateDescription": "리소스를 연결하기 위해 새 사이트를 생성하세요.", "close": "닫기", "siteErrorCreate": "사이트 생성 오류", "siteErrorCreateKeyPair": "키 쌍 또는 사이트 기본값을 찾을 수 없습니다", "siteErrorCreateDefaults": "사이트 기본값을 찾을 수 없습니다", "method": "방법", "siteMethodDescription": "이것이 연결을 노출하는 방법입니다.", "siteLearnNewt": "시스템에 Newt 설치하는 방법 배우기", "siteSeeConfigOnce": "구성을 한 번만 볼 수 있습니다.", "siteLoadWGConfig": "WireGuard 구성 로딩 중...", "siteDocker": "Docker 배포 세부정보 확장", "toggle": "전환", "dockerCompose": "도커 컴포즈", "dockerRun": "도커 실행", "siteLearnLocal": "로컬 사이트는 터널링하지 않습니다. 자세히 알아보기", "siteConfirmCopy": "구성을 복사했습니다.", "searchSitesProgress": "사이트 검색...", "siteAdd": "사이트 추가", "siteInstallNewt": "Newt 설치", "siteInstallNewtDescription": "시스템에서 Newt 실행하기", "WgConfiguration": "WireGuard 구성", "WgConfigurationDescription": "네트워크에 연결하기 위한 다음 구성을 사용하세요.", "operatingSystem": "운영 체제", "commands": "명령", "recommended": "추천", "siteNewtDescription": "최고의 사용자 경험을 위해 Newt를 사용하십시오. Newt는 WireGuard를 기반으로 하며, 판골린 대시보드 내에서 개인 네트워크의 LAN 주소로 개인 리소스에 접근할 수 있도록 합니다.", "siteRunsInDocker": "Docker에서 실행", "siteRunsInShell": "macOS, Linux 및 Windows에서 셸에서 실행", "siteErrorDelete": "사이트 삭제 오류", "siteErrorUpdate": "사이트 업데이트에 실패했습니다", "siteErrorUpdateDescription": "사이트 업데이트 중 오류가 발생했습니다.", "siteUpdated": "사이트가 업데이트되었습니다", "siteUpdatedDescription": "사이트가 업데이트되었습니다.", "siteGeneralDescription": "이 사이트에 대한 일반 설정을 구성하세요.", "siteSettingDescription": "사이트에서 설정을 구성하세요.", "siteSetting": "{siteName} 설정", "siteNewtTunnel": "뉴트 사이트 (추천)", "siteNewtTunnelDescription": "네트워크의 진입점을 생성하는 가장 쉬운 방법입니다. 추가 설정이 필요 없습니다.", "siteWg": "기본 WireGuard", "siteWgDescription": "모든 WireGuard 클라이언트를 사용하여 터널을 설정하세요. 수동 NAT 설정이 필요합니다.", "siteWgDescriptionSaas": "모든 WireGuard 클라이언트를 사용하여 터널을 설정하세요. 수동 NAT 설정이 필요합니다. 자체 호스팅 노드에서만 작동합니다.", "siteLocalDescription": "로컬 리소스만 사용 가능합니다. 터널링이 없습니다.", "siteLocalDescriptionSaas": "로컬 리소스 전용. 터널링 금지. 원격 노드에서만 사용 가능합니다.", "siteSeeAll": "모든 사이트 보기", "siteTunnelDescription": "사이트에 연결하는 방법을 결정하세요.", "siteNewtCredentials": "자격 증명", "siteNewtCredentialsDescription": "이것이 사이트가 서버와 인증하는 방법입니다.", "remoteNodeCredentialsDescription": "원격 노드가 서버와 인증하는 방법입니다.", "siteCredentialsSave": "자격 증명 저장", "siteCredentialsSaveDescription": "이것은 한 번만 볼 수 있습니다. 안전한 장소에 복사해 두세요.", "siteInfo": "사이트 정보", "status": "상태", "shareTitle": "공유 링크 관리", "shareDescription": "공유 가능한 링크를 생성하여 프록시 리소스에 임시 또는 영구적으로 액세스하세요.", "shareSearch": "공유 링크 검색...", "shareCreate": "공유 링크 생성", "shareErrorDelete": "링크 삭제에 실패했습니다.", "shareErrorDeleteMessage": "링크 삭제 중 오류가 발생했습니다.", "shareDeleted": "링크가 삭제되었습니다.", "shareDeletedDescription": "링크가 삭제되었습니다.", "shareTokenDescription": "액세스 토큰은 쿼리 매개변수 또는 요청 헤더의 두 가지 방법으로 전달될 수 있습니다. 이는 인증된 액세스를 위해 클라이언트에서 모든 요청마다 전달되어야 합니다.", "accessToken": "액세스 토큰", "usageExamples": "사용 예", "tokenId": "토큰 ID", "requestHeades": "요청 헤더", "queryParameter": "쿼리 매개변수", "importantNote": "중요한 참고 사항", "shareImportantDescription": "보안상의 이유로 가능한 경우 쿼리 매개변수보다 헤더를 사용하는 것이 권장됩니다. 쿼리 매개변수는 서버 로그나 브라우저 기록에 기록될 수 있습니다.", "token": "토큰", "shareTokenSecurety": "액세스 토큰을 안전하게 유지하세요. 공개적으로 접근 가능한 영역이나 클라이언트 측 코드에서 공유하지 마세요.", "shareErrorFetchResource": "리소스를 가져오는 데 실패했습니다.", "shareErrorFetchResourceDescription": "리소스를 가져오는 중 오류가 발생했습니다.", "shareErrorCreate": "공유 링크 생성에 실패했습니다.", "shareErrorCreateDescription": "공유 링크를 생성하는 동안 오류가 발생했습니다", "shareCreateDescription": "이 링크가 있는 누구나 리소스에 접근할 수 있습니다.", "shareTitleOptional": "제목 (선택 사항)", "expireIn": "만료됨", "neverExpire": "만료되지 않음", "shareExpireDescription": "만료 시간은 링크가 사용 가능하고 리소스에 접근할 수 있는 기간입니다. 이 시간이 지나면 링크는 더 이상 작동하지 않으며, 이 링크를 사용한 사용자는 리소스에 대한 접근 권한을 잃게 됩니다.", "shareSeeOnce": "이 링크는 한 번만 볼 수 있습니다. 반드시 복사해 두세요.", "shareAccessHint": "이 링크가 있는 누구나 리소스에 접근할 수 있습니다. 주의해서 공유하세요.", "shareTokenUsage": "액세스 토큰 사용 보기", "createLink": "링크 생성", "resourcesNotFound": "리소스가 발견되지 않았습니다.", "resourceSearch": "리소스 검색", "openMenu": "메뉴 열기", "resource": "리소스", "title": "제목", "created": "생성됨", "expires": "만료", "never": "절대", "shareErrorSelectResource": "리소스를 선택하세요", "proxyResourceTitle": "공개 리소스 관리", "proxyResourceDescription": "웹 브라우저를 통해 공용으로 접근할 수 있는 리소스를 생성하고 관리하세요.", "proxyResourcesBannerTitle": "웹 기반 공공 접근", "proxyResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.", "clientResourceTitle": "개인 리소스 관리", "clientResourceDescription": "연결된 클라이언트를 통해서만 접근할 수 있는 리소스를 생성하고 관리하세요.", "privateResourcesBannerTitle": "제로 트러스트 개인 접근", "privateResourcesBannerDescription": "개인 리소스는 제로 트러스트 보안을 사용하여, 사용자와 장치가 명시적으로 허용된 리소스에만 접근할 수 있도록 보장합니다. 이러한 리소스에 접근하려면 안전한 가상 사설 네트워크를 통해 사용자 장치 또는 머신 클라이언트를 연결하십시오.", "resourcesSearch": "리소스 검색...", "resourceAdd": "리소스 추가", "resourceErrorDelte": "리소스 삭제 중 오류 발생", "authentication": "인증", "protected": "보호됨", "notProtected": "보호되지 않음", "resourceMessageRemove": "제거되면 리소스에 더 이상 접근할 수 없습니다. 리소스와 연결된 모든 대상도 제거됩니다.", "resourceQuestionRemove": "조직에서 리소스를 제거하시겠습니까?", "resourceHTTP": "HTTPS 리소스", "resourceHTTPDescription": "완전한 도메인 이름을 사용해 RAW 또는 HTTPS로 프록시 요청을 수행합니다.", "resourceRaw": "원시 TCP/UDP 리소스", "resourceRawDescription": "포트 번호를 사용하여 RAW TCP/UDP로 요청을 프록시합니다.", "resourceRawDescriptionCloud": "원시 TCP/UDP를 포트 번호를 사용하여 프록시 요청합니다. 원격 노드 사용이 필요합니다.", "resourceCreate": "리소스 생성", "resourceCreateDescription": "아래 단계를 따라 새 리소스를 생성하세요.", "resourceSeeAll": "모든 리소스 보기", "resourceInfo": "리소스 정보", "resourceNameDescription": "이것은 리소스의 표시 이름입니다.", "siteSelect": "사이트 선택", "siteSearch": "사이트 검색", "siteNotFound": "사이트를 찾을 수 없습니다.", "selectCountry": "국가 선택하기", "searchCountries": "국가 검색...", "noCountryFound": "국가를 찾을 수 없습니다.", "siteSelectionDescription": "이 사이트는 대상에 대한 연결을 제공합니다.", "resourceType": "리소스 유형", "resourceTypeDescription": "리소스에 액세스하는 방법을 결정하세요.", "resourceHTTPSSettings": "HTTPS 설정", "resourceHTTPSSettingsDescription": "리소스가 HTTPS로 접근할 수 있는 방식을 구성합니다.", "domainType": "도메인 유형", "subdomain": "서브도메인", "baseDomain": "기본 도메인", "subdomnainDescription": "리소스에 접근할 수 있는 하위 도메인입니다.", "resourceRawSettings": "TCP/UDP 설정", "resourceRawSettingsDescription": "TCP/UDP를 통해 리소스에 접근하는 방법을 구성하세요.", "protocol": "프로토콜", "protocolSelect": "프로토콜 선택", "resourcePortNumber": "포트 번호", "resourcePortNumberDescription": "요청을 프록시하기 위한 외부 포트 번호입니다.", "back": "뒤로", "cancel": "취소", "resourceConfig": "구성 스니펫", "resourceConfigDescription": "TCP/UDP 리소스를 설정하기 위해 이 구성 스니펫을 복사하여 붙여넣습니다.", "resourceAddEntrypoints": "Traefik: 엔트리포인트 추가", "resourceExposePorts": "Gerbil: Docker Compose에서 포트 노출", "resourceLearnRaw": "TCP/UDP 리소스 구성 방법 알아보기", "resourceBack": "리소스로 돌아가기", "resourceGoTo": "리소스로 이동", "resourceDelete": "리소스 삭제", "resourceDeleteConfirm": "리소스 삭제 확인", "visibility": "가시성", "enabled": "활성화됨", "disabled": "비활성화됨", "general": "일반", "generalSettings": "일반 설정", "proxy": "프록시", "internal": "내부", "rules": "규칙", "resourceSettingDescription": "리소스의 설정을 구성하세요.", "resourceSetting": "{resourceName} 설정", "alwaysAllow": "인증 우회", "alwaysDeny": "접근 차단", "passToAuth": "인증으로 전달", "orgSettingsDescription": "조직의 일반 설정을 구성하세요.", "orgGeneralSettings": "조직 설정", "orgGeneralSettingsDescription": "조직 세부정보 및 구성을 관리하세요.", "saveGeneralSettings": "일반 설정 저장", "saveSettings": "설정 저장", "orgDangerZone": "위험 구역", "orgDangerZoneDescription": "이 조직을 삭제하면 되돌릴 수 없습니다. 확실히 하세요.", "orgDelete": "조직 삭제", "orgDeleteConfirm": "조직 삭제 확인", "orgMessageRemove": "이 작업은 되돌릴 수 없으며 모든 관련 데이터를 삭제합니다.", "orgMessageConfirm": "확인을 위해 아래에 조직 이름을 입력하십시오.", "orgQuestionRemove": "조직을 삭제하시겠습니까?", "orgUpdated": "조직이 업데이트되었습니다.", "orgUpdatedDescription": "조직이 업데이트되었습니다.", "orgErrorUpdate": "조직 업데이트에 실패했습니다.", "orgErrorUpdateMessage": "조직을 업데이트하는 동안 오류가 발생했습니다.", "orgErrorFetch": "조직을 가져오는 데 실패했습니다.", "orgErrorFetchMessage": "조직을 나열하는 동안 오류가 발생했습니다", "orgErrorDelete": "조직 삭제에 실패했습니다.", "orgErrorDeleteMessage": "조직을 삭제하는 중 오류가 발생했습니다.", "orgDeleted": "조직이 삭제되었습니다.", "orgDeletedMessage": "조직과 그 데이터가 삭제되었습니다.", "deleteAccount": "계정 삭제", "deleteAccountDescription": "계정, 소유한 모든 조직 및 조직 내의 모든 데이터를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", "deleteAccountButton": "계정 삭제", "deleteAccountConfirmTitle": "계정 삭제", "deleteAccountConfirmMessage": "이 작업은 귀하의 계정, 소유한 모든 조직 및 조직 내 모든 데이터를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", "deleteAccountConfirmString": "계정 삭제", "deleteAccountSuccess": "계정 삭제됨", "deleteAccountSuccessMessage": "계정이 삭제되었습니다.", "deleteAccountError": "계정 삭제 실패", "deleteAccountPreviewAccount": "귀하의 계정", "deleteAccountPreviewOrgs": "귀하가 소유한 조직(포함된 모든 데이터)", "orgMissing": "조직 ID가 누락되었습니다", "orgMissingMessage": "조직 ID 없이 초대장을 재생성할 수 없습니다.", "accessUsersManage": "사용자 관리", "accessUsersDescription": "이 조직에 액세스할 사용자 초대 및 관리", "accessUsersSearch": "사용자 검색...", "accessUserCreate": "사용자 생성", "accessUserRemove": "사용자 제거", "username": "사용자 이름", "identityProvider": "아이덴티티 공급자", "role": "역할", "nameRequired": "이름은 필수입니다", "accessRolesManage": "역할 관리", "accessRolesDescription": "조직의 사용자에 대한 역할을 생성하고 관리합니다.", "accessRolesSearch": "역할 검색...", "accessRolesAdd": "역할 추가", "accessRoleDelete": "역할 삭제", "accessApprovalsManage": "승인 관리", "accessApprovalsDescription": "이 조직의 접근 승인 대기를 보고 관리하세요.", "description": "설명", "inviteTitle": "열린 초대", "inviteDescription": "다른 사용자가 조직에 참여하도록 초대장을 관리합니다.", "inviteSearch": "초대 검색...", "minutes": "분", "hours": "시간", "days": "일", "weeks": "주", "months": "개월", "years": "연도", "day": "{count, plural, one {#일} other {#일}}", "apiKeysTitle": "API 키 정보", "apiKeysConfirmCopy2": "API 키를 복사했음을 확인해야 합니다.", "apiKeysErrorCreate": "API 키 생성 오류", "apiKeysErrorSetPermission": "권한 설정 오류", "apiKeysCreate": "API 키 생성", "apiKeysCreateDescription": "조직을 위한 새로운 API 키 생성", "apiKeysGeneralSettings": "권한", "apiKeysGeneralSettingsDescription": "이 API 키가 수행할 수 있는 작업 결정", "apiKeysList": "새로운 API 키", "apiKeysSave": "API 키 저장", "apiKeysSaveDescription": "이것은 한 번만 볼 수 있습니다. 안전한 장소에 복사해 두세요.", "apiKeysInfo": "API 키는 다음과 같습니다:", "apiKeysConfirmCopy": "API 키를 복사했습니다", "generate": "생성", "done": "완료", "apiKeysSeeAll": "모든 API 키 보기", "apiKeysPermissionsErrorLoadingActions": "API 키 작업 로드 오류", "apiKeysPermissionsErrorUpdate": "권한 설정 오류", "apiKeysPermissionsUpdated": "권한이 업데이트되었습니다", "apiKeysPermissionsUpdatedDescription": "권한이 업데이트되었습니다.", "apiKeysPermissionsGeneralSettings": "권한", "apiKeysPermissionsGeneralSettingsDescription": "이 API 키가 수행할 수 있는 작업 결정", "apiKeysPermissionsSave": "권한 저장", "apiKeysPermissionsTitle": "권한", "apiKeys": "API 키", "searchApiKeys": "API 키 검색...", "apiKeysAdd": "API 키 생성", "apiKeysErrorDelete": "API 키 삭제 오류", "apiKeysErrorDeleteMessage": "API 키 삭제 오류", "apiKeysQuestionRemove": "조직에서 API 키를 제거하시겠습니까?", "apiKeysMessageRemove": "삭제되면 API 키를 더 이상 사용할 수 없습니다.", "apiKeysDeleteConfirm": "API 키 삭제 확인", "apiKeysDelete": "API 키 삭제", "apiKeysManage": "API 키 관리", "apiKeysDescription": "API 키는 통합 API와 인증하는 데 사용됩니다.", "apiKeysSettings": "{apiKeyName} 설정", "userTitle": "모든 사용자 관리", "userDescription": "시스템의 모든 사용자를 보고 관리합니다", "userAbount": "사용자 관리에 대한 정보", "userAbountDescription": "이 표는 시스템의 모든 루트 사용자 객체를 표시합니다. 각 사용자는 여러 조직에 속할 수 있습니다. 사용자를 조직에서 제거해도 루트 사용자 객체는 삭제되지 않으며, 시스템에 남아 있습니다. 사용자를 시스템에서 완전히 제거하려면 이 표의 삭제 작업을 사용하여 루트 사용자 객체를 삭제해야 합니다.", "userServer": "서버 사용자", "userSearch": "서버 사용자 검색 중...", "userErrorDelete": "사용자 삭제 오류", "userDeleteConfirm": "사용자 삭제 확인", "userDeleteServer": "서버에서 사용자 삭제", "userMessageRemove": "사용자가 모든 조직에서 제거되며 서버에서 완전히 삭제됩니다.", "userQuestionRemove": "서버에서 사용자를 영구적으로 삭제하시겠습니까?", "licenseKey": "라이센스 키", "valid": "유효", "numberOfSites": "사이트 수", "licenseKeySearch": "라이센스 키 검색 중...", "licenseKeyAdd": "라이센스 키 추가", "type": "유형", "licenseKeyRequired": "라이센스 키가 필요합니다", "licenseTermsAgree": "라이선스 조건에 동의해야 합니다.", "licenseErrorKeyLoad": "라이센스 키를 로드하는 데 실패했습니다.", "licenseErrorKeyLoadDescription": "라이센스 키 로드 중 오류가 발생했습니다.", "licenseErrorKeyDelete": "라이센스 키 삭제에 실패했습니다.", "licenseErrorKeyDeleteDescription": "라이센스 키 삭제 중 오류가 발생했습니다.", "licenseKeyDeleted": "라이센스 키가 삭제되었습니다.", "licenseKeyDeletedDescription": "라이센스 키가 삭제되었습니다.", "licenseErrorKeyActivate": "라이센스 키 활성화에 실패했습니다.", "licenseErrorKeyActivateDescription": "라이센스 키를 활성화하는 동안 오류가 발생했습니다", "licenseAbout": "라이센스에 대한 정보", "communityEdition": "커뮤니티 에디션", "licenseAboutDescription": "이것은 상업적 환경에서 Pangolin을 사용하는 비즈니스 및 기업 사용자용입니다. 개인 용도로 Pangolin을 사용하는 경우 이 섹션을 무시할 수 있습니다.", "licenseKeyActivated": "라이센스 키가 활성화되었습니다", "licenseKeyActivatedDescription": "라이센스 키가 성공적으로 활성화되었습니다.", "licenseErrorKeyRecheck": "라이센스 키 재확인 실패", "licenseErrorKeyRecheckDescription": "라이센스 키를 재확인하는 중 오류가 발생했습니다.", "licenseErrorKeyRechecked": "라이센스 키가 재확인되었습니다.", "licenseErrorKeyRecheckedDescription": "모든 라이센스 키가 재검사되었습니다.", "licenseActivateKey": "라이센스 키 활성화", "licenseActivateKeyDescription": "라이센스 키를 입력하여 활성화하십시오.", "licenseActivate": "라이센스 활성화", "licenseAgreement": "이 상자를 체크함으로써, 귀하는 귀하의 라이선스 키와 관련된 계층에 해당하는 라이선스 조건을 읽고 동의했음을 확인합니다.", "fossorialLicense": "Fossorial 상업 라이선스 및 구독 약관 보기", "licenseMessageRemove": "이 작업은 라이센스 키와 그에 의해 부여된 모든 관련 권한을 제거합니다.", "licenseMessageConfirm": "확인을 위해 아래에 라이센스 키를 입력하세요.", "licenseQuestionRemove": "라이선스 키를 삭제하시겠습니까?", "licenseKeyDelete": "라이센스 키 삭제", "licenseKeyDeleteConfirm": "라이센스 키 삭제 확인", "licenseTitle": "라이선스 상태 관리", "licenseTitleDescription": "시스템에서 라이센스 키를 보고 관리합니다.", "licenseHost": "호스트 라이센스", "licenseHostDescription": "호스트의 주요 라이센스 키를 관리합니다.", "licensedNot": "라이센스 없음", "hostId": "호스트 ID", "licenseReckeckAll": "모든 키 재확인", "licenseSiteUsage": "사이트 사용량", "licenseSiteUsageDecsription": "이 라이센스를 사용하는 사이트 수를 확인하세요.", "licenseNoSiteLimit": "라이선스가 없는 호스트를 사용하는 사이트 수에 제한이 없습니다.", "licensePurchase": "라이센스 구매", "licensePurchaseSites": "추가 사이트 구매", "licenseSitesUsedMax": "{maxSites}개의 사이트 중 {usedSites}개 사용 중", "licenseSitesUsed": "시스템에 {count, plural, =0 {# 사이트} one {# 사이트} other {# 사이트}}가 있습니다.", "licensePurchaseDescription": "구매할 사이트 수를 선택하세요 {selectedMode, select, license {라이센스를 구매합니다. 나중에 더 많은 사이트를 추가할 수 있습니다.} other {기존 라이센스에 추가합니다.}}", "licenseFee": "라이선스 요금", "licensePriceSite": "사이트당 가격", "total": "총계", "licenseContinuePayment": "결제로 진행", "pricingPage": "가격 페이지", "pricingPortal": "구매 포털 보기", "licensePricingPage": "가장 최신의 가격 및 할인 정보를 보려면 방문하십시오 ", "invite": "초대", "inviteRegenerate": "초대장 재생성", "inviteRegenerateDescription": "이전 초대를 취소하고 새로 생성", "inviteRemove": "초대 제거", "inviteRemoveError": "초대 제거 실패", "inviteRemoveErrorDescription": "초대를 제거하는 동안 오류가 발생했습니다.", "inviteRemoved": "초대가 제거되었습니다.", "inviteRemovedDescription": "{email}에 대한 초대가 삭제되었습니다.", "inviteQuestionRemove": "초대를 제거하시겠습니까?", "inviteMessageRemove": "한 번 제거되면 이 초대는 더 이상 유효하지 않습니다. 나중에 사용자를 다시 초대할 수 있습니다.", "inviteMessageConfirm": "확인을 위해 아래 초대의 이메일 주소를 입력해 주세요.", "inviteQuestionRegenerate": "{email}에 대한 초대장을 다시 생성하시겠습니까? 이전 초대장은 취소됩니다.", "inviteRemoveConfirm": "초대 제거 확인", "inviteRegenerated": "초대 재생성됨", "inviteSent": "새 초대장이 {email}로 전송되었습니다.", "inviteSentEmail": "사용자에게 이메일 알림 전송", "inviteGenerate": "{email}에 대한 새로운 초대장이 생성되었습니다.", "inviteDuplicateError": "초대 중복", "inviteDuplicateErrorDescription": "이 사용자에 대한 초대가 이미 존재합니다.", "inviteRateLimitError": "요청 한도 초과", "inviteRateLimitErrorDescription": "시간당 3회 재생성 한도를 초과했습니다. 나중에 다시 시도하세요.", "inviteRegenerateError": "초대 재생성 실패", "inviteRegenerateErrorDescription": "초대장을 재생성하는 동안 오류가 발생했습니다.", "inviteValidityPeriod": "유효 기간", "inviteValidityPeriodSelect": "유효 기간 선택", "inviteRegenerateMessage": "초대장이 다시 생성되었습니다. 사용자는 아래 링크에 접속하여 초대장을 수락해야 합니다.", "inviteRegenerateButton": "재생성", "expiresAt": "만료 시간", "accessRoleUnknown": "알 수 없는 역할", "placeholder": "자리 표시자", "userErrorOrgRemove": "사용자를 제거하지 못했습니다", "userErrorOrgRemoveDescription": "사용자를 제거하는 동안 오류가 발생했습니다.", "userOrgRemoved": "사용자가 제거되었습니다.", "userOrgRemovedDescription": "사용자 {email}가 조직에서 제거되었습니다.", "userQuestionOrgRemove": "조직에서 이 사용자를 제거하시겠습니까?", "userMessageOrgRemove": "이 사용자가 제거되면 더 이상 조직에 접근할 수 없습니다. 나중에 다시 초대할 수 있지만, 초대를 다시 수락해야 합니다.", "userRemoveOrgConfirm": "사용자 제거 확인", "userRemoveOrg": "조직에서 사용자 제거", "users": "사용자", "accessRoleMember": "회원", "accessRoleOwner": "소유자", "userConfirmed": "확인됨", "idpNameInternal": "내부", "emailInvalid": "유효하지 않은 이메일 주소입니다.", "inviteValidityDuration": "지속 시간을 선택하십시오.", "accessRoleSelectPlease": "역할을 선택하세요", "usernameRequired": "사용자 이름은 필수입니다.", "idpSelectPlease": "신원 제공자를 선택하십시오", "idpGenericOidc": "일반 OAuth2/OIDC 공급자.", "accessRoleErrorFetch": "역할을 가져오는 데 실패했습니다.", "accessRoleErrorFetchDescription": "역할을 가져오는 중 오류가 발생했습니다.", "idpErrorFetch": "신원 제공자를 가져오는 데 실패했습니다", "idpErrorFetchDescription": "신원 공급자를 가져오는 중 오류가 발생했습니다.", "userErrorExists": "사용자가 이미 존재합니다.", "userErrorExistsDescription": "이 사용자는 이미 조직의 구성원입니다.", "inviteError": "사용자 초대에 실패했습니다", "inviteErrorDescription": "사용자를 초대하는 동안 오류가 발생했습니다.", "userInvited": "사용자가 초대되었습니다.", "userInvitedDescription": "사용자가 성공적으로 초대되었습니다.", "userErrorCreate": "사용자 생성에 실패했습니다.", "userErrorCreateDescription": "사용자를 생성하는 동안 오류가 발생했습니다.", "userCreated": "사용자가 생성되었습니다.", "userCreatedDescription": "사용자가 성공적으로 생성되었습니다.", "userTypeInternal": "내부 사용자", "userTypeInternalDescription": "사용자를 초대하여 조직에 직접 참여하게 하세요.", "userTypeExternal": "외부 사용자", "userTypeExternalDescription": "외부 신원 공급자를 사용하여 사용자를 생성하세요.", "accessUserCreateDescription": "새 사용자를 만들기 위한 아래 단계를 따르세요.", "userSeeAll": "모든 사용자 보기", "userTypeTitle": "사용자 유형", "userTypeDescription": "사용자를 생성하는 방법을 결정하세요.", "userSettings": "사용자 정보", "userSettingsDescription": "새 사용자에 대한 세부정보를 입력하십시오.", "inviteEmailSent": "사용자에게 초대 이메일 보내기", "inviteValid": "유효 기간", "selectDuration": "지속 시간 선택", "selectResource": "리소스 선택", "filterByResource": "리소스별 필터", "selectApprovalState": "승인 상태 선택", "filterByApprovalState": "승인 상태로 필터링", "approvalListEmpty": "승인이 없습니다.", "approvalState": "승인 상태", "approvalLoadMore": "더 불러오기", "loadingApprovals": "승인 불러오는 중", "approve": "승인", "approved": "승인됨", "denied": "거부됨", "deniedApproval": "승인 거부됨", "all": "모두", "deny": "거부", "viewDetails": "세부 정보 보기", "requestingNewDeviceApproval": "새 장치를 요청함", "resetFilters": "필터 재설정", "totalBlocked": "Pangolin으로 차단된 요청", "totalRequests": "총 요청 수", "requestsByCountry": "국가별 요청 수", "requestsByDay": "일자별 요청 수", "blocked": "차단됨", "allowed": "허용됨", "topCountries": "상위 국가", "accessRoleSelect": "역할 선택", "inviteEmailSentDescription": "아래의 접근 링크와 함께 사용자에게 이메일이 전송되었습니다. 사용자는 초대를 수락하기 위해 링크에 접근해야 합니다.", "inviteSentDescription": "사용자가 초대되었습니다. 초대를 수락하려면 아래 링크에 접속해야 합니다.", "inviteExpiresIn": "초대는 {days, plural, one {#일} other {#일}} 후에 만료됩니다.", "idpTitle": "아이덴티티 공급자", "idpSelect": "외부 사용자를 위한 아이덴티티 공급자를 선택하십시오", "idpNotConfigured": "구성된 아이덴티티 공급자가 없습니다. 외부 사용자를 생성하기 전에 아이덴티티 공급자를 구성하십시오.", "usernameUniq": "선택한 아이덴티티 공급자에 존재하는 고유한 사용자 이름과 일치해야 합니다.", "emailOptional": "이메일 (선택 사항)", "nameOptional": "이름 (선택 사항)", "accessControls": "접근 제어", "userDescription2": "이 사용자의 설정 관리", "accessRoleErrorAdd": "사용자를 역할에 추가하는 데 실패했습니다.", "accessRoleErrorAddDescription": "사용자를 역할에 추가하는 동안 오류가 발생했습니다.", "userSaved": "사용자 저장됨", "userSavedDescription": "사용자가 업데이트되었습니다.", "autoProvisioned": "자동 프로비저닝됨", "autoProvisionedDescription": "이 사용자가 ID 공급자에 의해 자동으로 관리될 수 있도록 허용합니다", "accessControlsDescription": "이 사용자가 조직에서 접근하고 수행할 수 있는 작업을 관리하세요", "accessControlsSubmit": "접근 제어 저장", "roles": "역할", "accessUsersRoles": "사용자 및 역할 관리", "accessUsersRolesDescription": "사용자를 초대하고 역할에 추가하여 조직에 대한 접근을 관리하세요", "key": "키", "createdAt": "생성일", "proxyErrorInvalidHeader": "잘못된 사용자 정의 호스트 헤더 값입니다. 도메인 이름 형식을 사용하거나 사용자 정의 호스트 헤더를 해제하려면 비워 두십시오.", "proxyErrorTls": "유효하지 않은 TLS 서버 이름입니다. 도메인 이름 형식을 사용하거나 비워 두어 TLS 서버 이름을 제거하십시오.", "proxyEnableSSL": "SSL 활성화", "proxyEnableSSLDescription": "타겟과의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화를 활성화하세요.", "target": "대상", "configureTarget": "대상 구성", "targetErrorFetch": "대상 가져오는 데 실패했습니다.", "targetErrorFetchDescription": "대상 가져오는 중 오류가 발생했습니다", "siteErrorFetch": "리소스를 가져오는 데 실패했습니다", "siteErrorFetchDescription": "리소스를 가져오는 동안 오류가 발생했습니다", "targetErrorDuplicate": "중복 대상", "targetErrorDuplicateDescription": "이 설정을 가진 대상이 이미 존재합니다", "targetWireGuardErrorInvalidIp": "유효하지 않은 대상 IP", "targetWireGuardErrorInvalidIpDescription": "대상 IP는 사이트 서브넷 내에 있어야 합니다.", "targetsUpdated": "대상 업데이트됨", "targetsUpdatedDescription": "대상 및 설정이 성공적으로 업데이트되었습니다.", "targetsErrorUpdate": "대상 업데이트 실패", "targetsErrorUpdateDescription": "대상 업데이트 중 오류가 발생했습니다.", "targetTlsUpdate": "TLS 설정이 업데이트되었습니다.", "targetTlsUpdateDescription": "TLS 설정이 성공적으로 업데이트되었습니다", "targetErrorTlsUpdate": "TLS 설정 업데이트에 실패했습니다.", "targetErrorTlsUpdateDescription": "TLS 설정을 업데이트하는 동안 오류가 발생했습니다", "proxyUpdated": "프록시 설정이 업데이트되었습니다.", "proxyUpdatedDescription": "프록시 설정이 성공적으로 업데이트되었습니다", "proxyErrorUpdate": "프록시 설정 업데이트에 실패했습니다.", "proxyErrorUpdateDescription": "프록시 설정을 업데이트하는 동안 오류가 발생했습니다", "targetAddr": "호스트", "targetPort": "포트", "targetProtocol": "프로토콜", "targetTlsSettings": "보안 연결 구성", "targetTlsSettingsDescription": "리소스에 대한 SSL/TLS 설정 구성", "targetTlsSettingsAdvanced": "고급 TLS 설정", "targetTlsSni": "TLS 서버 이름", "targetTlsSniDescription": "SNI에 사용할 TLS 서버 이름. 기본값을 사용하려면 비워 두십시오.", "targetTlsSubmit": "설정 저장", "targets": "대상 구성", "targetsDescription": "사용자 백엔드 서비스로 트래픽을 라우팅할 대상을 설정하십시오.", "targetStickySessions": "스티키 세션 활성화", "targetStickySessionsDescription": "세션 전체 동안 동일한 백엔드 대상을 유지합니다.", "methodSelect": "선택 방법", "targetSubmit": "대상 추가", "targetNoOne": "이 리소스에는 대상이 없습니다. 백엔드로 요청을 보낼 대상을 구성하려면 대상을 추가하세요.", "targetNoOneDescription": "위에 하나 이상의 대상을 추가하면 로드 밸런싱이 활성화됩니다.", "targetsSubmit": "대상 저장", "addTarget": "대상 추가", "targetErrorInvalidIp": "유효하지 않은 IP 주소", "targetErrorInvalidIpDescription": "유효한 IP 주소 또는 호스트 이름을 입력하세요.", "targetErrorInvalidPort": "유효하지 않은 포트", "targetErrorInvalidPortDescription": "유효한 포트 번호를 입력하세요.", "targetErrorNoSite": "선택된 사이트 없음", "targetErrorNoSiteDescription": "대상을 위해 사이트를 선택하세요.", "targetCreated": "대상 생성", "targetCreatedDescription": "대상이 성공적으로 생성되었습니다.", "targetErrorCreate": "대상 생성 실패", "targetErrorCreateDescription": "대상 생성 중 오류가 발생했습니다.", "tlsServerName": "TLS 서버 이름", "tlsServerNameDescription": "SNI를 위한 TLS 서버 이름", "save": "저장", "proxyAdditional": "추가 프록시 설정", "proxyAdditionalDescription": "리소스가 프록시 설정을 처리하는 방법 구성", "proxyCustomHeader": "사용자 정의 호스트 헤더", "proxyCustomHeaderDescription": "요청을 프록시할 때 설정할 호스트 헤더입니다. 기본값을 사용하려면 비워 두십시오.", "proxyAdditionalSubmit": "프록시 설정 저장", "subnetMaskErrorInvalid": "유효하지 않은 서브넷 마스크입니다. 0에서 32 사이여야 합니다.", "ipAddressErrorInvalidFormat": "잘못된 IP 주소 형식", "ipAddressErrorInvalidOctet": "유효하지 않은 IP 주소 옥텟", "path": "경로", "matchPath": "경로 맞춤", "ipAddressRange": "IP 범위", "rulesErrorFetch": "규칙을 가져오는 데 실패했습니다.", "rulesErrorFetchDescription": "규칙을 가져오는 중 오류가 발생했습니다", "rulesErrorDuplicate": "중복 규칙", "rulesErrorDuplicateDescription": "이 설정을 가진 규칙이 이미 존재합니다.", "rulesErrorInvalidIpAddressRange": "유효하지 않은 CIDR", "rulesErrorInvalidIpAddressRangeDescription": "유효한 CIDR 값을 입력하십시오.", "rulesErrorInvalidUrl": "유효하지 않은 URL 경로", "rulesErrorInvalidUrlDescription": "유효한 URL 경로 값을 입력해 주세요.", "rulesErrorInvalidIpAddress": "유효하지 않은 IP", "rulesErrorInvalidIpAddressDescription": "유효한 IP 주소를 입력하세요", "rulesErrorUpdate": "규칙 업데이트에 실패했습니다.", "rulesErrorUpdateDescription": "규칙 업데이트 중 오류가 발생했습니다.", "rulesUpdated": "규칙 활성화", "rulesUpdatedDescription": "규칙 평가가 업데이트되었습니다", "rulesMatchIpAddressRangeDescription": "CIDR 형식으로 주소를 입력하세요 (예: 103.21.244.0/22)", "rulesMatchIpAddress": "IP 주소를 입력하세요 (예: 103.21.244.12)", "rulesMatchUrl": "URL 경로 또는 패턴을 입력하세요 (예: /api/v1/todos 또는 /api/v1/*)", "rulesErrorInvalidPriority": "유효하지 않은 우선순위", "rulesErrorInvalidPriorityDescription": "유효한 우선 순위를 입력하세요.", "rulesErrorDuplicatePriority": "중복 우선순위", "rulesErrorDuplicatePriorityDescription": "고유한 우선 순위를 입력하십시오.", "ruleUpdated": "규칙이 업데이트되었습니다", "ruleUpdatedDescription": "규칙이 성공적으로 업데이트되었습니다", "ruleErrorUpdate": "작업 실패", "ruleErrorUpdateDescription": "저장 작업 중 오류가 발생했습니다.", "rulesPriority": "우선순위", "rulesAction": "작업", "rulesMatchType": "일치 유형", "value": "값", "rulesAbout": "규칙에 대한 정보", "rulesAboutDescription": "규칙을 사용하면 IP 주소 또는 URL 경로를 기준으로 리소스에 대한 액세스를 제어할 수 있습니다. IP 주소 또는 URL 경로를 기준으로 액세스를 허용하거나 거부하는 규칙을 만들 수 있습니다.", "rulesActions": "작업", "rulesActionAlwaysAllow": "항상 허용: 모든 인증 방법 우회", "rulesActionAlwaysDeny": "항상 거부: 모든 요청을 차단합니다. 인증을 시도할 수 없습니다.", "rulesActionPassToAuth": "인증으로 전달: 인증 방법 시도를 허용합니다", "rulesMatchCriteria": "일치 기준", "rulesMatchCriteriaIpAddress": "특정 IP 주소와 일치", "rulesMatchCriteriaIpAddressRange": "CIDR 표기법으로 IP 주소 범위를 일치시킵니다", "rulesMatchCriteriaUrl": "URL 경로 또는 패턴 일치", "rulesEnable": "규칙 활성화", "rulesEnableDescription": "이 리소스에 대한 규칙 평가를 활성화하거나 비활성화합니다.", "rulesResource": "리소스 규칙 구성", "rulesResourceDescription": "리소스에 대한 접근을 제어하는 규칙 구성", "ruleSubmit": "규칙 추가", "rulesNoOne": "규칙이 없습니다. 양식을 사용하여 규칙을 추가하십시오.", "rulesOrder": "규칙은 우선 순위에 따라 오름차순으로 평가됩니다.", "rulesSubmit": "규칙 저장", "resourceErrorCreate": "리소스 생성 오류", "resourceErrorCreateDescription": "리소스를 생성하는 중 오류가 발생했습니다.", "resourceErrorCreateMessage": "리소스 생성 오류:", "resourceErrorCreateMessageDescription": "예기치 않은 오류가 발생했습니다.", "sitesErrorFetch": "사이트를 가져오는 중 오류가 발생했습니다.", "sitesErrorFetchDescription": "사이트를 가져오는 중 오류가 발생했습니다", "domainsErrorFetch": "도메인 가져오기 오류", "domainsErrorFetchDescription": "도메인을 가져오는 중 오류가 발생했습니다.", "none": "없음", "unknown": "알 수 없음", "resources": "리소스", "resourcesDescription": "리소스는 개인 네트워크에서 실행 중인 애플리케이션에 대한 프록시입니다. 개인 네트워크에서 HTTP/HTTPS 또는 원시 TCP/UDP 서비스에 대한 리소스를 생성하십시오. 각 리소스는 암호화된 WireGuard 터널을 통해 개인적이고 안전한 연결을 가능하게 하려면 사이트에 연결되어야 합니다.", "resourcesWireGuardConnect": "WireGuard 암호화를 통한 안전한 연결", "resourcesMultipleAuthenticationMethods": "다중 인증 방법 구성", "resourcesUsersRolesAccess": "사용자 및 역할 기반 접근 제어", "resourcesErrorUpdate": "리소스를 전환하는 데 실패했습니다.", "resourcesErrorUpdateDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.", "access": "접속", "accessControl": "액세스 제어", "shareLink": "{resource} 공유 링크", "resourceSelect": "리소스 선택", "shareLinks": "공유 링크", "share": "공유 가능한 링크", "shareDescription2": "리소스에 대한 공유 가능한 링크를 생성하세요. 링크는 리소스에 대한 임시 또는 무제한 액세스를 제공합니다. 링크를 생성할 때 만료 기간을 설정할 수 있습니다.", "shareEasyCreate": "생성하고 공유하기 쉬움", "shareConfigurableExpirationDuration": "구성 가능한 만료 기간", "shareSecureAndRevocable": "안전하고 철회 가능", "nameMin": "이름은 최소 {len}자 이상이어야 합니다.", "nameMax": "이름은 {len}자보다 길 수 없습니다.", "sitesConfirmCopy": "구성을 복사했는지 확인하십시오.", "unknownCommand": "알 수 없는 명령", "newtErrorFetchReleases": "릴리스 정보를 가져오는 데 실패했습니다: {err}", "newtErrorFetchLatest": "최신 릴리스를 가져오는 중 오류 발생: {err}", "newtEndpoint": "엔드포인트", "newtId": "ID", "newtSecretKey": "비밀", "architecture": "아키텍처", "sites": "사이트", "siteWgAnyClients": "WireGuard 클라이언트를 사용하여 연결하십시오. 피어 IP를 사용하여 내부 리소스에 접근해야 합니다.", "siteWgCompatibleAllClients": "모든 WireGuard 클라이언트와 호환", "siteWgManualConfigurationRequired": "수동 구성이 필요합니다.", "userErrorNotAdminOrOwner": "사용자는 관리자 또는 소유자가 아닙니다.", "pangolinSettings": "설정 - 판골린", "accessRoleYour": "귀하의 역할:", "accessRoleSelect2": "역할 선택", "accessUserSelect": "사용자를 선택하세요.", "otpEmailEnter": "이메일을 입력하세요", "otpEmailEnterDescription": "입력 필드에 입력한 후 Enter 키를 눌러 이메일을 추가합니다.", "otpEmailErrorInvalid": "유효하지 않은 이메일 주소입니다. 와일드카드(*)는 전체 로컬 부분이어야 합니다.", "otpEmailSmtpRequired": "SMTP 필요", "otpEmailSmtpRequiredDescription": "일회성 비밀번호 인증을 사용하려면 서버에서 SMTP가 활성화되어 있어야 합니다.", "otpEmailTitle": "일회용 비밀번호", "otpEmailTitleDescription": "리소스 접근을 위한 이메일 기반 인증 필요", "otpEmailWhitelist": "이메일 화이트리스트", "otpEmailWhitelistList": "화이트리스트된 이메일", "otpEmailWhitelistListDescription": "이 이메일 주소를 가진 사용자만 이 리소스에 접근할 수 있습니다. 그들은 이메일로 전송된 일회용 비밀번호를 입력하라는 메시지를 받게 됩니다. 도메인에서 모든 이메일 주소를 허용하기 위해 와일드카드(*@example.com)를 사용할 수 있습니다.", "otpEmailWhitelistSave": "허용 목록 저장", "passwordAdd": "비밀번호 추가", "passwordRemove": "비밀번호 제거", "pincodeAdd": "PIN 코드 추가", "pincodeRemove": "PIN 코드 제거", "resourceAuthMethods": "인증 방법", "resourceAuthMethodsDescriptions": "추가 인증 방법을 통해 리소스에 대한 액세스 허용", "resourceAuthSettingsSave": "성공적으로 저장되었습니다.", "resourceAuthSettingsSaveDescription": "인증 설정이 저장되었습니다", "resourceErrorAuthFetch": "데이터를 가져오는 데 실패했습니다.", "resourceErrorAuthFetchDescription": "데이터를 가져오는 중 오류가 발생했습니다.", "resourceErrorPasswordRemove": "리소스 비밀번호 제거 오류", "resourceErrorPasswordRemoveDescription": "리소스 비밀번호를 제거하는 동안 오류가 발생했습니다.", "resourceErrorPasswordSetup": "리소스 비밀번호 설정 오류", "resourceErrorPasswordSetupDescription": "리소스 비밀번호 설정 중 오류가 발생했습니다", "resourceErrorPincodeRemove": "리소스 핀 코드 제거 오류", "resourceErrorPincodeRemoveDescription": "리소스 핀코드를 제거하는 중 오류가 발생했습니다.", "resourceErrorPincodeSetup": "리소스 PIN 코드 설정 중 오류가 발생했습니다.", "resourceErrorPincodeSetupDescription": "리소스 PIN 코드를 설정하는 동안 오류가 발생했습니다.", "resourceErrorUsersRolesSave": "역할 설정에 실패했습니다.", "resourceErrorUsersRolesSaveDescription": "역할 설정 중 오류가 발생했습니다.", "resourceErrorWhitelistSave": "화이트리스트 저장에 실패했습니다.", "resourceErrorWhitelistSaveDescription": "화이트리스트를 저장하는 동안 오류가 발생했습니다.", "resourcePasswordSubmit": "비밀번호 보호 활성화", "resourcePasswordProtection": "비밀번호 보호 {status}", "resourcePasswordRemove": "리소스 비밀번호가 제거되었습니다", "resourcePasswordRemoveDescription": "리소스 비밀번호가 성공적으로 제거되었습니다.", "resourcePasswordSetup": "리소스 비밀번호 설정됨", "resourcePasswordSetupDescription": "리소스 비밀번호가 성공적으로 설정되었습니다.", "resourcePasswordSetupTitle": "비밀번호 설정", "resourcePasswordSetupTitleDescription": "이 리소스를 보호하기 위해 비밀번호를 설정하세요.", "resourcePincode": "PIN 코드", "resourcePincodeSubmit": "PIN 코드 보호 활성화", "resourcePincodeProtection": "PIN 코드 보호 {상태}", "resourcePincodeRemove": "리소스 핀코드가 제거되었습니다.", "resourcePincodeRemoveDescription": "리소스 비밀번호가 성공적으로 제거되었습니다.", "resourcePincodeSetup": "리소스 PIN 코드가 설정되었습니다", "resourcePincodeSetupDescription": "리소스 핀코드가 성공적으로 설정되었습니다", "resourcePincodeSetupTitle": "핀코드 설정", "resourcePincodeSetupTitleDescription": "이 리소스를 보호하기 위해 핀 코드를 설정하십시오.", "resourceRoleDescription": "관리자는 항상 이 리소스에 접근할 수 있습니다.", "resourceUsersRoles": "접근 제어", "resourceUsersRolesDescription": "이 리소스를 방문할 수 있는 사용자 및 역할을 구성하십시오", "resourceUsersRolesSubmit": "접근 제어 저장", "resourceWhitelistSave": "성공적으로 저장되었습니다.", "resourceWhitelistSaveDescription": "허용 목록 설정이 저장되었습니다.", "ssoUse": "플랫폼 SSO 사용", "ssoUseDescription": "기존 사용자는 이 기능이 활성화된 모든 리소스에 대해 한 번만 로그인하면 됩니다.", "proxyErrorInvalidPort": "유효하지 않은 포트 번호", "subdomainErrorInvalid": "잘못된 하위 도메인", "domainErrorFetch": "도메인 가져오기 오류", "domainErrorFetchDescription": "도메인을 가져오는 중 오류가 발생했습니다.", "resourceErrorUpdate": "리소스 업데이트에 실패했습니다.", "resourceErrorUpdateDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.", "resourceUpdated": "리소스가 업데이트되었습니다.", "resourceUpdatedDescription": "리소스가 성공적으로 업데이트되었습니다.", "resourceErrorTransfer": "리소스 전송에 실패했습니다", "resourceErrorTransferDescription": "리소스를 전송하는 동안 오류가 발생했습니다", "resourceTransferred": "리소스가 전송되었습니다.", "resourceTransferredDescription": "리소스가 성공적으로 전송되었습니다.", "resourceErrorToggle": "리소스를 전환하는 데 실패했습니다.", "resourceErrorToggleDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.", "resourceVisibilityTitle": "가시성", "resourceVisibilityTitleDescription": "리소스 가시성을 완전히 활성화하거나 비활성화", "resourceGeneral": "일반 설정", "resourceGeneralDescription": "이 리소스에 대한 일반 설정을 구성하십시오.", "resourceEnable": "리소스 활성화", "resourceTransfer": "리소스 전송", "resourceTransferDescription": "이 리소스를 다른 사이트로 전송", "resourceTransferSubmit": "리소스 전송", "siteDestination": "대상 사이트", "searchSites": "사이트 검색", "countries": "국가", "accessRoleCreate": "역할 생성", "accessRoleCreateDescription": "사용자를 그룹화하고 권한을 관리하기 위해 새 역할을 생성하세요.", "accessRoleEdit": "역할 편집", "accessRoleEditDescription": "역할 정보 편집.", "accessRoleCreateSubmit": "역할 생성", "accessRoleCreated": "역할이 생성되었습니다.", "accessRoleCreatedDescription": "역할이 성공적으로 생성되었습니다.", "accessRoleErrorCreate": "역할 생성 실패", "accessRoleErrorCreateDescription": "역할 생성 중 오류가 발생했습니다.", "accessRoleUpdateSubmit": "역할 업데이트", "accessRoleUpdated": "역할 업데이트됨", "accessRoleUpdatedDescription": "역할이 성공적으로 업데이트되었습니다.", "accessApprovalUpdated": "승인 처리됨", "accessApprovalApprovedDescription": "승인 요청을 승인으로 설정.", "accessApprovalDeniedDescription": "승인 요청을 거부로 설정.", "accessRoleErrorUpdate": "역할 업데이트 실패", "accessRoleErrorUpdateDescription": "역할 업데이트 중 오류 발생.", "accessApprovalErrorUpdate": "승인 처리 실패", "accessApprovalErrorUpdateDescription": "승인 처리 중 오류가 발생했습니다.", "accessRoleErrorNewRequired": "새 역할이 필요합니다.", "accessRoleErrorRemove": "역할 제거에 실패했습니다.", "accessRoleErrorRemoveDescription": "역할을 제거하는 동안 오류가 발생했습니다.", "accessRoleName": "역할 이름", "accessRoleQuestionRemove": "`{name}` 역할을 삭제하려고 합니다. 이 작업은 되돌릴 수 없습니다.", "accessRoleRemove": "역할 제거", "accessRoleRemoveDescription": "조직에서 역할 제거", "accessRoleRemoveSubmit": "역할 제거", "accessRoleRemoved": "역할이 제거되었습니다", "accessRoleRemovedDescription": "역할이 성공적으로 제거되었습니다.", "accessRoleRequiredRemove": "이 역할을 삭제하기 전에 기존 구성원을 전송할 새 역할을 선택하세요.", "network": "네트워크", "manage": "관리", "sitesNotFound": "사이트를 찾을 수 없습니다.", "pangolinServerAdmin": "서버 관리자 - 판골린", "licenseTierProfessional": "전문 라이센스", "licenseTierEnterprise": "기업 라이선스", "licenseTierPersonal": "개인 라이선스", "licensed": "라이센스", "yes": "예", "no": "아니요", "sitesAdditional": "추가 사이트", "licenseKeys": "라이센스 키", "sitestCountDecrease": "사이트 수 줄이기", "sitestCountIncrease": "사이트 수 증가", "idpManage": "아이덴티티 공급자 관리", "idpManageDescription": "시스템에서 ID 제공자를 보고 관리합니다", "idpGlobalModeBanner": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. 관리자 패널에서 글로벌 IdP를 관리하십시오. 조직별 IdP를 활성화하려면 서버 설정을 편집하고 IdP 모드를 조직으로 설정하십시오. 문서 보기. 글로벌 IdP 사용을 계속하고 조직 설정에서 이 항목을 제거하려면 설정에서 모드를 글로벌로 명시적으로 설정하십시오.", "idpGlobalModeBannerUpgradeRequired": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. 관리자 패널에서 글로벌 IdP를 관리하십시오. 조직별 신원 제공자를 사용하려면 Enterprise 에디션으로 업그레이드해야 합니다.", "idpGlobalModeBannerLicenseRequired": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. 관리자 패널에서 글로벌 IdP를 관리하십시오. 조직별 신원 제공자를 사용하려면 엔터프라이즈 라이선스가 필요합니다.", "idpDeletedDescription": "신원 공급자가 성공적으로 삭제되었습니다", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "아이덴티티 공급자를 영구적으로 삭제하시겠습니까?", "idpMessageRemove": "이 작업은 아이덴티티 공급자와 모든 관련 구성을 제거합니다. 이 공급자를 통해 인증하는 사용자는 더 이상 로그인할 수 없습니다.", "idpMessageConfirm": "확인을 위해 아래에 아이덴티티 제공자의 이름을 입력하세요.", "idpConfirmDelete": "신원 제공자 삭제 확인", "idpDelete": "아이덴티티 공급자 삭제", "idp": "신원 공급자", "idpSearch": "ID 공급자 검색...", "idpAdd": "아이덴티티 공급자 추가", "idpClientIdRequired": "클라이언트 ID가 필요합니다.", "idpClientSecretRequired": "클라이언트 비밀이 필요합니다.", "idpErrorAuthUrlInvalid": "인증 URL은 유효한 URL이어야 합니다.", "idpErrorTokenUrlInvalid": "토큰 URL은 유효한 URL이어야 합니다.", "idpPathRequired": "식별자 경로가 필요합니다.", "idpScopeRequired": "범위가 필요합니다.", "idpOidcDescription": "OpenID Connect ID 공급자를 구성하십시오.", "idpCreatedDescription": "ID 공급자가 성공적으로 생성되었습니다.", "idpCreate": "아이덴티티 공급자 생성", "idpCreateDescription": "사용자 인증을 위한 새로운 ID 공급자를 구성합니다.", "idpSeeAll": "모든 ID 공급자 보기", "idpSettingsDescription": "신원 제공자의 기본 정보를 구성하세요", "idpDisplayName": "이 신원 공급자를 위한 표시 이름", "idpAutoProvisionUsers": "사용자 자동 프로비저닝", "idpAutoProvisionUsersDescription": "활성화되면 사용자가 첫 로그인 시 시스템에 자동으로 생성되며, 사용자와 역할 및 조직을 매핑할 수 있습니다.", "licenseBadge": "EE", "idpType": "제공자 유형", "idpTypeDescription": "구성할 ID 공급자의 유형을 선택하십시오.", "idpOidcConfigure": "OAuth2/OIDC 구성", "idpOidcConfigureDescription": "OAuth2/OIDC 공급자 엔드포인트 및 자격 증명을 구성하십시오.", "idpClientId": "클라이언트 ID", "idpClientIdDescription": "아이덴티티 공급자의 OAuth2 클라이언트 ID", "idpClientSecret": "클라이언트 비밀", "idpClientSecretDescription": "신원 제공자로부터의 OAuth2 클라이언트 비밀", "idpAuthUrl": "인증 URL", "idpAuthUrlDescription": "OAuth2 인증 엔드포인트 URL", "idpTokenUrl": "토큰 URL", "idpTokenUrlDescription": "OAuth2 토큰 엔드포인트 URL", "idpOidcConfigureAlert": "중요 정보", "idpOidcConfigureAlertDescription": "아이덴티티 공급자를 생성한 후, 아이덴티티 공급자의 설정에서 콜백 URL을 구성해야 합니다. 콜백 URL은 성공적으로 생성된 후 제공됩니다.", "idpToken": "토큰 구성", "idpTokenDescription": "ID 토큰에서 사용자 정보를 추출하는 방법 구성", "idpJmespathAbout": "JMESPath에 대하여", "idpJmespathAboutDescription": "아래 경로는 ID 토큰에서 값을 추출하기 위해 JMESPath 구문을 사용합니다.", "idpJmespathAboutDescriptionLink": "JMESPath에 대해 더 알아보기", "idpJmespathLabel": "식별자 경로", "idpJmespathLabelDescription": "ID 토큰에서 사용자 식별자에 대한 경로", "idpJmespathEmailPathOptional": "이메일 경로 (선택 사항)", "idpJmespathEmailPathOptionalDescription": "ID 토큰에서 사용자의 이메일 경로", "idpJmespathNamePathOptional": "이름 경로 (선택 사항)", "idpJmespathNamePathOptionalDescription": "ID 토큰에서 사용자의 이름 경로", "idpOidcConfigureScopes": "범위", "idpOidcConfigureScopesDescription": "요청할 OAuth2 범위의 공백으로 구분된 목록", "idpSubmit": "아이덴티티 공급자 생성", "orgPolicies": "조직 정책", "idpSettings": "{idpName} 설정", "idpCreateSettingsDescription": "아이덴티티 공급자의 설정을 구성하십시오", "roleMapping": "역할 매핑", "orgMapping": "조직 매핑", "orgPoliciesSearch": "조직 정책 검색...", "orgPoliciesAdd": "조직 정책 추가", "orgRequired": "조직은 필수입니다.", "error": "오류", "success": "성공", "orgPolicyAddedDescription": "정책이 성공적으로 추가되었습니다", "orgPolicyUpdatedDescription": "정책이 성공적으로 업데이트되었습니다.", "orgPolicyDeletedDescription": "정책이 성공적으로 삭제되었습니다", "defaultMappingsUpdatedDescription": "기본 매핑이 성공적으로 업데이트되었습니다.", "orgPoliciesAbout": "조직 정책에 대하여", "orgPoliciesAboutDescription": "조직 정책은 사용자의 ID 토큰에 따라 조직에 대한 액세스를 제어하는 데 사용됩니다. ID 토큰에서 역할 및 조직 정보를 추출하기 위해 JMESPath 표현식을 지정할 수 있습니다.", "orgPoliciesAboutDescriptionLink": "자세한 내용은 문서를 참조하십시오.", "defaultMappingsOptional": "기본 매핑(선택 사항)", "defaultMappingsOptionalDescription": "조직에 대해 정의된 정책이 없을 때 기본 매핑이 사용됩니다. 여기에서 기본 역할 및 조직 매핑을 지정하여 대체할 수 있습니다.", "defaultMappingsRole": "기본 역할 매핑", "defaultMappingsRoleDescription": "이 표현식의 결과는 조직에서 정의된 역할 이름을 문자열로 반환해야 합니다.", "defaultMappingsOrg": "기본 조직 매핑", "defaultMappingsOrgDescription": "이 표현식은 사용자가 조직에 접근할 수 있도록 조직 ID 또는 true를 반환해야 합니다.", "defaultMappingsSubmit": "기본 매핑 저장", "orgPoliciesEdit": "조직 정책 편집", "org": "조직", "orgSelect": "조직 선택", "orgSearch": "조직 검색", "orgNotFound": "조직을 찾을 수 없습니다.", "roleMappingPathOptional": "역할 매핑 경로 (선택 사항)", "orgMappingPathOptional": "조직 매핑 경로 (선택 사항)", "orgPolicyUpdate": "정책 업데이트", "orgPolicyAdd": "정책 추가", "orgPolicyConfig": "조직에 대한 접근을 구성하십시오.", "idpUpdatedDescription": "아이덴티티 제공자가 성공적으로 업데이트되었습니다", "redirectUrl": "리디렉션 URL", "orgIdpRedirectUrls": "리디렉션 URL", "redirectUrlAbout": "리디렉션 URL에 대한 정보", "redirectUrlAboutDescription": "사용자가 인증 후 리디렉션될 URL입니다. 이 URL을 신원 제공자 설정에서 구성해야 합니다.", "pangolinAuth": "인증 - 판골린", "verificationCodeLengthRequirements": "인증 코드가 8자여야 합니다.", "errorOccurred": "오류가 발생했습니다.", "emailErrorVerify": "이메일 확인에 실패했습니다:", "emailVerified": "이메일이 성공적으로 확인되었습니다! 리디렉션 중입니다...", "verificationCodeErrorResend": "인증 코드를 재전송하는 데 실패했습니다:", "verificationCodeResend": "인증 코드가 재전송되었습니다", "verificationCodeResendDescription": "검증 코드를 귀하의 이메일 주소로 재전송했습니다. 받은 편지함을 확인해 주세요.", "emailVerify": "이메일 확인", "emailVerifyDescription": "이메일 주소로 전송된 인증 코드를 입력하세요.", "verificationCode": "인증 코드", "verificationCodeEmailSent": "귀하의 이메일 주소로 인증 코드가 전송되었습니다.", "submit": "제출", "emailVerifyResendProgress": "재전송 중...", "emailVerifyResend": "코드를 받지 못하셨나요? 여기 클릭하여 재전송하세요", "passwordNotMatch": "비밀번호가 일치하지 않습니다.", "signupError": "가입하는 동안 오류가 발생했습니다.", "pangolinLogoAlt": "판골린 로고", "inviteAlready": "초대받은 것 같습니다!", "inviteAlreadyDescription": "초대를 수락하려면 로그인하거나 계정을 생성해야 합니다.", "signupQuestion": "이미 계정이 있습니까?", "login": "로그인", "resourceNotFound": "리소스를 찾을 수 없습니다", "resourceNotFoundDescription": "접근하려는 리소스가 존재하지 않습니다.", "pincodeRequirementsLength": "PIN은 정확히 6자리여야 합니다", "pincodeRequirementsChars": "PIN은 숫자만 포함해야 합니다.", "passwordRequirementsLength": "비밀번호는 최소 1자 이상이어야 합니다", "passwordRequirementsTitle": "비밀번호 요구사항:", "passwordRequirementLength": "최소 8자 이상", "passwordRequirementUppercase": "최소 대문자 하나", "passwordRequirementLowercase": "최소 소문자 하나", "passwordRequirementNumber": "최소 숫자 하나", "passwordRequirementSpecial": "최소 특수 문자 하나", "passwordRequirementsMet": "✓ 비밀번호가 모든 요구사항을 충족합니다.", "passwordStrength": "비밀번호 강도", "passwordStrengthWeak": "약함", "passwordStrengthMedium": "보통", "passwordStrengthStrong": "강함", "passwordRequirements": "요구 사항:", "passwordRequirementLengthText": "8자 이상", "passwordRequirementUppercaseText": "대문자 (A-Z)", "passwordRequirementLowercaseText": "소문자 (a-z)", "passwordRequirementNumberText": "숫자 (0-9)", "passwordRequirementSpecialText": "특수 문자 (!@#$%...)", "passwordsDoNotMatch": "비밀번호가 일치하지 않습니다.", "otpEmailRequirementsLength": "OTP는 최소 1자 이상이어야 합니다", "otpEmailSent": "OTP 전송됨", "otpEmailSentDescription": "OTP가 귀하의 이메일로 전송되었습니다.", "otpEmailErrorAuthenticate": "이메일로 인증에 실패했습니다", "pincodeErrorAuthenticate": "핀코드로 인증하는 데 실패했습니다", "passwordErrorAuthenticate": "비밀번호로 인증하는 데 실패했습니다.", "poweredBy": "제공자", "authenticationRequired": "인증 필요", "authenticationMethodChoose": "{name}에 접근하기 위한 선호하는 방법을 선택하세요.", "authenticationRequest": "{name}에 접근하려면 인증해야 합니다.", "user": "사용자", "pincodeInput": "6자리 PIN 코드", "pincodeSubmit": "PIN으로 로그인", "passwordSubmit": "비밀번호로 로그인", "otpEmailDescription": "일회성 코드가 이 이메일로 전송됩니다.", "otpEmailSend": "일회성 코드 전송", "otpEmail": "일회성 비밀번호 (OTP)", "otpEmailSubmit": "OTP 제출", "backToEmail": "이메일로 돌아가기", "noSupportKey": "서버가 지원 키 없이 실행되고 있습니다. 프로젝트 지원을 고려하세요!", "accessDenied": "접근 거부", "accessDeniedDescription": "이 리소스에 접근할 수 있는 권한이 없습니다. 이게 실수라면 관리자에게 문의해 주세요.", "accessTokenError": "액세스 토큰 확인 중 오류 발생", "accessGranted": "접근 허가됨", "accessUrlInvalid": "접근 URL이 유효하지 않습니다", "accessGrantedDescription": "이 리소스에 대한 접근이 허용되었습니다. 리디렉션 중입니다...", "accessUrlInvalidDescription": "이 공유 액세스 URL은 유효하지 않습니다. 새로운 URL을 위해 리소스 소유자에게 문의하세요.", "tokenInvalid": "유효하지 않은 토큰", "pincodeInvalid": "유효하지 않은 코드", "passwordErrorRequestReset": "재설정을 요청하는 데 실패했습니다:", "passwordErrorReset": "비밀번호 재설정 실패:", "passwordResetSuccess": "비밀번호가 성공적으로 재설정되었습니다! 로그인으로 돌아가기...", "passwordReset": "비밀번호 재설정", "passwordResetDescription": "비밀번호를 재설정하는 단계를 따르세요", "passwordResetSent": "이 이메일 주소로 비밀번호 재설정 코드를 전송하겠습니다.", "passwordResetCode": "코드 재설정", "passwordResetCodeDescription": "재설정 코드를 확인하려면 이메일을 확인하세요.", "generatePasswordResetCode": "비밀번호 초기화 코드 생성", "passwordResetCodeGenerated": "비밀번호 초기화 코드 생성됨", "passwordResetCodeGeneratedDescription": "이 코드를 사용자와 공유하세요. 사용자는 이를 사용하여 비밀번호를 재설정할 수 있습니다.", "passwordResetUrl": "리디렉션 URL", "passwordNew": "새 비밀번호", "passwordNewConfirm": "새 비밀번호 확인", "changePassword": "비밀번호 변경", "changePasswordDescription": "계정 비밀번호를 업데이트하십시오", "oldPassword": "현재 비밀번호", "newPassword": "새 비밀번호", "confirmNewPassword": "새 비밀번호 확인", "changePasswordError": "비밀번호 변경 실패", "changePasswordErrorDescription": "비밀번호를 변경하는 중 오류가 발생했습니다", "changePasswordSuccess": "비밀번호 변경 완료", "changePasswordSuccessDescription": "비밀번호가 성공적으로 업데이트되었습니다", "passwordExpiryRequired": "비밀번호 만료 필요", "passwordExpiryDescription": "이 조직은 {maxDays}일마다 비밀번호 변경을 요구합니다.", "changePasswordNow": "지금 비밀번호 변경", "pincodeAuth": "인증 코드", "pincodeSubmit2": "코드 제출", "passwordResetSubmit": "재설정 요청", "passwordResetAlreadyHaveCode": "코드를 입력하십시오.", "passwordResetSmtpRequired": "관리자에게 문의하십시오", "passwordResetSmtpRequiredDescription": "비밀번호를 재설정하려면 비밀번호 초기화 코드가 필요합니다. 지원을 받으려면 관리자에게 문의하십시오.", "passwordBack": "비밀번호로 돌아가기", "loginBack": "메인 로그인 페이지로 돌아갑니다.", "signup": "가입하기", "loginStart": "시작하려면 로그인하세요.", "idpOidcTokenValidating": "OIDC 토큰 검증 중", "idpOidcTokenResponse": "OIDC 토큰 응답 검증", "idpErrorOidcTokenValidating": "OIDC 토큰 검증 오류", "idpConnectingTo": "{name}에 연결 중", "idpConnectingToDescription": "귀하의 신원을 확인하는 중", "idpConnectingToProcess": "연결 중...", "idpConnectingToFinished": "연결됨", "idpErrorConnectingTo": "{name}에 연결하는 데 문제가 발생했습니다. 관리자에게 문의하십시오.", "idpErrorNotFound": "IdP를 찾을 수 없습니다.", "inviteInvalid": "유효하지 않은 초대", "inviteInvalidDescription": "초대 링크가 유효하지 않습니다.", "inviteErrorWrongUser": "이 초대는 이 사용자에게 해당되지 않습니다", "inviteErrorUserNotExists": "사용자가 존재하지 않습니다. 먼저 계정을 생성해 주세요.", "inviteErrorLoginRequired": "초대를 수락하려면 로그인해야 합니다.", "inviteErrorExpired": "초대가 만료되었을 수 있습니다.", "inviteErrorRevoked": "초대가 취소되었을 수 있습니다.", "inviteErrorTypo": "초대 링크에 오타가 있을 수 있습니다.", "pangolinSetup": "설정 - 판골린", "orgNameRequired": "조직 이름은 필수입니다.", "orgIdRequired": "조직 ID가 필요합니다", "orgIdMaxLength": "조직 ID는 최대 32자 이내여야 합니다", "orgErrorCreate": "조직 생성 중 오류가 발생했습니다.", "pageNotFound": "페이지를 찾을 수 없습니다", "pageNotFoundDescription": "앗! 찾고 있는 페이지가 존재하지 않습니다.", "overview": "개요", "home": "홈", "settings": "설정", "usersAll": "모든 사용자", "license": "라이선스", "pangolinDashboard": "대시보드 - 판골린", "noResults": "결과를 찾을 수 없습니다.", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "입력된 태그", "tagsEnteredDescription": "입력한 태그는 다음과 같습니다.", "tagsWarnCannotBeLessThanZero": "maxTags와 minTags는 0보다 작을 수 없습니다", "tagsWarnNotAllowedAutocompleteOptions": "자동 완성 옵션에 따라 태그가 허용되지 않습니다", "tagsWarnInvalid": "validateTag에 따라 유효하지 않은 태그입니다", "tagWarnTooShort": "태그 {tagText}가 너무 짧습니다", "tagWarnTooLong": "태그 {tagText}가 너무 깁니다.", "tagsWarnReachedMaxNumber": "허용된 최대 태그 수에 도달했습니다.", "tagWarnDuplicate": "중복 태그 {tagText}가 추가되지 않았습니다.", "supportKeyInvalid": "유효하지 않은 키", "supportKeyInvalidDescription": "지원자 키가 유효하지 않습니다.", "supportKeyValid": "유효한 키", "supportKeyValidDescription": "귀하의 후원자 키가 검증되었습니다. 지원해 주셔서 감사합니다!", "supportKeyErrorValidationDescription": "서포터 키 유효성 검사에 실패했습니다.", "supportKey": "개발 지원 및 판골린을 입양하세요!", "supportKeyDescription": "커뮤니티를 위해 Pangolin 개발을 지속할 수 있도록 후원자 키를 구매하세요. 귀하의 기여는 모든 사용자를 위해 애플리케이션을 유지하고 새로운 기능을 추가하는 데 더 많은 시간을 할애할 수 있게 해줍니다. 우리는 절대 이 기능을 유료화하는 데 사용하지 않을 것입니다. 이는 상업용 에디션과는 별개입니다.", "supportKeyPet": "자신만의 애완 판골린을 입양하고 만날 수 있습니다!", "supportKeyPurchase": "결제는 GitHub를 통해 처리됩니다. 이후, 키를 다음에서 검색할 수 있습니다.", "supportKeyPurchaseLink": "우리 웹사이트", "supportKeyPurchase2": "여기에서 사용하세요.", "supportKeyLearnMore": "자세히 알아보기.", "supportKeyOptions": "가장 적합한 옵션을 선택해 주세요.", "supportKetOptionFull": "전체 후원자", "forWholeServer": "전체 서버에 대해", "lifetimePurchase": "평생 구매", "supporterStatus": "후원자 상태", "buy": "구매", "supportKeyOptionLimited": "제한된 후원자", "forFiveUsers": "5명 이하의 사용자에 대해", "supportKeyRedeem": "서포터 키 사용", "supportKeyHideSevenDays": "7일 동안 숨기기", "supportKeyEnter": "지원자 키 입력", "supportKeyEnterDescription": "당신만의 펭귄 애완동물을 만나보세요!", "githubUsername": "GitHub 사용자 이름", "supportKeyInput": "후원자 키", "supportKeyBuy": "서포터 키 구매", "logoutError": "로그아웃 중 오류 발생", "signingAs": "로그인한 사용자", "serverAdmin": "서버 관리자", "managedSelfhosted": "관리 자체 호스팅", "otpEnable": "이중 인증 활성화", "otpDisable": "이중 인증 비활성화", "logout": "로그 아웃", "licenseTierProfessionalRequired": "전문 에디션이 필요합니다.", "licenseTierProfessionalRequiredDescription": "이 기능은 Professional Edition에서만 사용할 수 있습니다.", "actionGetOrg": "조직 가져오기", "updateOrgUser": "조직 사용자 업데이트", "createOrgUser": "조직 사용자 생성", "actionUpdateOrg": "조직 업데이트", "actionRemoveInvitation": "초대 제거", "actionUpdateUser": "사용자 업데이트", "actionGetUser": "사용자 조회", "actionGetOrgUser": "조직 사용자 가져오기", "actionListOrgDomains": "조직 도메인 목록", "actionGetDomain": "도메인 가져오기", "actionCreateOrgDomain": "도메인 생성", "actionUpdateOrgDomain": "도메인 업데이트", "actionDeleteOrgDomain": "도메인 삭제", "actionGetDNSRecords": "DNS 레코드 가져오기", "actionRestartOrgDomain": "도메인 재시작", "actionCreateSite": "사이트 생성", "actionDeleteSite": "사이트 삭제", "actionGetSite": "사이트 가져오기", "actionListSites": "사이트 목록", "actionApplyBlueprint": "청사진 적용", "actionListBlueprints": "청사진 목록", "actionGetBlueprint": "청사진 가져오기", "setupToken": "설정 토큰", "setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.", "setupTokenRequired": "설정 토큰이 필요합니다", "actionUpdateSite": "사이트 업데이트", "actionListSiteRoles": "허용된 사이트 역할 목록", "actionCreateResource": "리소스 생성", "actionDeleteResource": "리소스 삭제", "actionGetResource": "리소스 가져오기", "actionListResource": "리소스 목록", "actionUpdateResource": "리소스 업데이트", "actionListResourceUsers": "리소스 사용자 목록", "actionSetResourceUsers": "리소스 사용자 설정", "actionSetAllowedResourceRoles": "허용된 리소스 역할 설정", "actionListAllowedResourceRoles": "허용된 리소스 역할 목록", "actionSetResourcePassword": "리소스 비밀번호 설정", "actionSetResourcePincode": "리소스 핀코드 설정", "actionSetResourceEmailWhitelist": "리소스 이메일 화이트리스트 설정", "actionGetResourceEmailWhitelist": "리소스 이메일 화이트리스트 가져오기", "actionCreateTarget": "대상 만들기", "actionDeleteTarget": "대상 삭제", "actionGetTarget": "대상 가져오기", "actionListTargets": "대상 목록", "actionUpdateTarget": "대상 업데이트", "actionCreateRole": "역할 생성", "actionDeleteRole": "역할 삭제", "actionGetRole": "역할 가져오기", "actionListRole": "역할 목록", "actionUpdateRole": "역할 업데이트", "actionListAllowedRoleResources": "허용된 역할 리소스 목록", "actionInviteUser": "사용자 초대", "actionRemoveUser": "사용자 제거", "actionListUsers": "사용자 목록", "actionAddUserRole": "사용자 역할 추가", "actionGenerateAccessToken": "액세스 토큰 생성", "actionDeleteAccessToken": "액세스 토큰 삭제", "actionListAccessTokens": "액세스 토큰 목록", "actionCreateResourceRule": "리소스 규칙 생성", "actionDeleteResourceRule": "리소스 규칙 삭제", "actionListResourceRules": "리소스 규칙 목록", "actionUpdateResourceRule": "리소스 규칙 업데이트", "actionListOrgs": "조직 목록", "actionCheckOrgId": "ID 확인", "actionCreateOrg": "조직 생성", "actionDeleteOrg": "조직 삭제", "actionListApiKeys": "API 키 목록", "actionListApiKeyActions": "API 키 작업 목록", "actionSetApiKeyActions": "API 키 허용 작업 설정", "actionCreateApiKey": "API 키 생성", "actionDeleteApiKey": "API 키 삭제", "actionCreateIdp": "IDP 생성", "actionUpdateIdp": "IDP 업데이트", "actionDeleteIdp": "IDP 삭제", "actionListIdps": "IDP 목록", "actionGetIdp": "IDP 가져오기", "actionCreateIdpOrg": "IDP 조직 정책 생성", "actionDeleteIdpOrg": "IDP 조직 정책 삭제", "actionListIdpOrgs": "IDP 조직 목록", "actionUpdateIdpOrg": "IDP 조직 업데이트", "actionCreateClient": "클라이언트 생성", "actionDeleteClient": "클라이언트 삭제", "actionArchiveClient": "클라이언트 보관", "actionUnarchiveClient": "클라이언트 보관 취소", "actionBlockClient": "클라이언트 차단", "actionUnblockClient": "클라이언트 차단 해제", "actionUpdateClient": "클라이언트 업데이트", "actionListClients": "클라이언트 목록", "actionGetClient": "클라이언트 가져오기", "actionCreateSiteResource": "사이트 리소스 생성", "actionDeleteSiteResource": "사이트 리소스 삭제", "actionGetSiteResource": "사이트 리소스 가져오기", "actionListSiteResources": "사이트 리소스 목록", "actionUpdateSiteResource": "사이트 리소스 업데이트", "actionListInvitations": "초대 목록", "actionExportLogs": "로그 내보내기", "actionViewLogs": "로그 보기", "noneSelected": "선택된 항목 없음", "orgNotFound2": "조직이 없습니다.", "searchPlaceholder": "검색...", "emptySearchOptions": "옵션이 없습니다", "create": "생성", "orgs": "조직", "loginError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요.", "loginRequiredForDevice": "로그인이 필요합니다.", "passwordForgot": "비밀번호를 잊으셨나요?", "otpAuth": "이중 인증", "otpAuthDescription": "인증 앱에서 코드를 입력하거나 단일 사용 백업 코드 중 하나를 입력하세요.", "otpAuthSubmit": "코드 제출", "idpContinue": "또는 계속 진행하십시오.", "otpAuthBack": "비밀번호로 돌아가기", "navbar": "탐색 메뉴", "navbarDescription": "애플리케이션의 주요 탐색 메뉴", "navbarDocsLink": "문서", "otpErrorEnable": "2FA를 활성화할 수 없습니다.", "otpErrorEnableDescription": "2FA를 활성화하는 동안 오류가 발생했습니다", "otpSetupCheckCode": "6자리 코드를 입력하세요", "otpSetupCheckCodeRetry": "유효하지 않은 코드입니다. 다시 시도하세요.", "otpSetup": "이중 인증 활성화", "otpSetupDescription": "추가 보호 계층으로 계정을 안전하게 유지하세요.", "otpSetupScanQr": "인증 앱으로 이 QR 코드를 스캔하거나 비밀 키를 수동으로 입력하십시오:", "otpSetupSecretCode": "인증 코드", "otpSetupSuccess": "이중 인증 활성화됨", "otpSetupSuccessStoreBackupCodes": "귀하의 계정이 이제 더 안전해졌습니다. 백업 코드를 저장하는 것을 잊지 마세요.", "otpErrorDisable": "2FA를 비활성화할 수 없습니다.", "otpErrorDisableDescription": "2FA를 비활성화하는 동안 오류가 발생했습니다.", "otpRemove": "이중 인증 비활성화", "otpRemoveDescription": "계정에 대한 이중 인증 비활성화", "otpRemoveSuccess": "이중 인증 비활성화", "otpRemoveSuccessMessage": "이중 인증이 귀하의 계정에서 비활성화되었습니다. 언제든지 다시 활성화할 수 있습니다.", "otpRemoveSubmit": "2FA 비활성화", "paginator": "페이지 {current} / {last}", "paginatorToFirst": "첫 페이지로 이동", "paginatorToPrevious": "이전 페이지로 이동", "paginatorToNext": "다음 페이지로 이동", "paginatorToLast": "마지막 페이지로 이동", "copyText": "텍스트 복사", "copyTextFailed": "텍스트 복사 실패: ", "copyTextClipboard": "클립보드에 복사", "inviteErrorInvalidConfirmation": "유효하지 않은 확인", "passwordRequired": "비밀번호는 필수입니다.", "allowAll": "모두 허용", "permissionsAllowAll": "모든 권한 허용", "githubUsernameRequired": "GitHub 사용자 이름이 필요합니다.", "supportKeyRequired": "지원자 키가 필요합니다.", "passwordRequirementsChars": "비밀번호는 최소 8자 이상이어야 합니다", "language": "언어", "verificationCodeRequired": "코드가 필요합니다.", "userErrorNoUpdate": "업데이트할 사용자가 없습니다", "siteErrorNoUpdate": "업데이트할 사이트가 없습니다.", "resourceErrorNoUpdate": "업데이트할 리소스가 없습니다", "authErrorNoUpdate": "업데이트할 인증 정보가 없습니다.", "orgErrorNoUpdate": "업데이트할 조직이 없습니다.", "orgErrorNoProvided": "제공된 조직이 없습니다.", "apiKeysErrorNoUpdate": "업데이트할 API 키가 없습니다.", "sidebarOverview": "개요", "sidebarHome": "홈", "sidebarSites": "사이트", "sidebarApprovals": "승인 요청", "sidebarResources": "리소스", "sidebarProxyResources": "공유", "sidebarClientResources": "비공개", "sidebarAccessControl": "액세스 제어", "sidebarLogsAndAnalytics": "로그 및 분석", "sidebarTeam": "팀", "sidebarUsers": "사용자", "sidebarAdmin": "관리자", "sidebarInvitations": "초대", "sidebarRoles": "역할", "sidebarShareableLinks": "링크", "sidebarApiKeys": "API 키", "sidebarSettings": "설정", "sidebarAllUsers": "모든 사용자", "sidebarIdentityProviders": "신원 공급자", "sidebarLicense": "라이선스", "sidebarClients": "클라이언트", "sidebarUserDevices": "사용자 장치", "sidebarMachineClients": "기계", "sidebarDomains": "도메인", "sidebarGeneral": "관리", "sidebarLogAndAnalytics": "로그 & 통계", "sidebarBluePrints": "청사진", "sidebarOrganization": "조직", "sidebarManagement": "관리", "sidebarBillingAndLicenses": "결제 및 라이선스", "sidebarLogsAnalytics": "분석", "blueprints": "청사진", "blueprintsDescription": "선언적 구성을 적용하고 이전 실행을 봅니다", "blueprintAdd": "청사진 추가", "blueprintGoBack": "모든 청사진 보기", "blueprintCreate": "청사진 생성", "blueprintCreateDescription2": "새 청사진을 생성하고 적용하려면 아래 단계를 따르십시오", "blueprintDetails": "청사진 세부사항", "blueprintDetailsDescription": "적용된 청사진의 결과와 발생한 오류를 확인합니다", "blueprintInfo": "청사진 정보", "message": "메시지", "blueprintContentsDescription": "인프라를 설명하는 YAML 내용을 정의하십시오", "blueprintErrorCreateDescription": "청사진을 적용하는 중 오류가 발생했습니다", "blueprintErrorCreate": "청사진 생성 오류", "searchBlueprintProgress": "청사진 검색...", "appliedAt": "적용 시점", "source": "출처", "contents": "콘텐츠", "parsedContents": "구문 분석된 콘텐츠 (읽기 전용)", "enableDockerSocket": "Docker 청사진 활성화", "enableDockerSocketDescription": "블루프린트 레이블을 위한 Docker 소켓 레이블 수집을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.", "viewDockerContainers": "도커 컨테이너 보기", "containersIn": "{siteName}의 컨테이너", "selectContainerDescription": "이 대상을 위한 호스트 이름으로 사용할 컨테이너를 선택하세요. 포트를 사용하려면 포트를 클릭하세요.", "containerName": "이름", "containerImage": "이미지", "containerState": "주", "containerNetworks": "네트워크", "containerHostnameIp": "호스트 이름/IP", "containerLabels": "레이블", "containerLabelsCount": "{count, plural, one {# 레이블} other {# 레이블}}", "containerLabelsTitle": "컨테이너 레이블", "containerLabelEmpty": "<비어 있음>", "containerPorts": "포트", "containerPortsMore": "+{count}개 더", "containerActions": "작업", "select": "선택", "noContainersMatchingFilters": "현재 필터와 일치하는 컨테이너를 찾을 수 없습니다.", "showContainersWithoutPorts": "포트가 없는 컨테이너 표시", "showStoppedContainers": "중지된 컨테이너 표시", "noContainersFound": "컨테이너를 찾을 수 없습니다. Docker 컨테이너가 실행 중인지 확인하십시오.", "searchContainersPlaceholder": "{count}개의 컨테이너에서 검색...", "searchResultsCount": "{count, plural, one {# 결과} other {# 결과}}", "filters": "필터", "filterOptions": "필터 옵션", "filterPorts": "포트", "filterStopped": "중지됨", "clearAllFilters": "모든 필터 지우기", "columns": "열", "toggleColumns": "열 전환", "refreshContainersList": "컨테이너 목록 새로 고침", "searching": "검색 중...", "noContainersFoundMatching": "\"{filter}\"와 일치하는 컨테이너를 찾을 수 없습니다.", "light": "빛", "dark": "어두운", "system": "시스템", "theme": "테마", "subnetRequired": "서브넷은 필수입니다", "initialSetupTitle": "초기 서버 설정", "initialSetupDescription": "초기 서버 관리자 계정을 생성하세요. 서버 관리자 계정은 하나만 존재할 수 있습니다. 이러한 자격 증명은 나중에 언제든지 변경할 수 있습니다.", "createAdminAccount": "관리자 계정 생성", "setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.", "certificateStatus": "인증서 상태", "loading": "로딩 중", "loadingAnalytics": "분석 로딩 중", "restart": "재시작", "domains": "도메인", "domainsDescription": "조직에서 사용 가능한 도메인 생성 및 관리", "domainsSearch": "도메인 검색...", "domainAdd": "도메인 추가", "domainAddDescription": "조직에 새로운 도메인을 등록하세요", "domainCreate": "도메인 생성", "domainCreatedDescription": "도메인이 성공적으로 생성되었습니다", "domainDeletedDescription": "도메인이 성공적으로 삭제되었습니다", "domainQuestionRemove": "도메인을 제거하시겠습니까?", "domainMessageRemove": "제거되면 도메인이 더 이상 계정과 연관되지 않습니다.", "domainConfirmDelete": "도메인 삭제 확인", "domainDelete": "도메인 삭제", "domain": "도메인", "selectDomainTypeNsName": "도메인 위임 (NS)", "selectDomainTypeNsDescription": "이 도메인과 모든 하위 도메인입니다. 전체 도메인 영역을 제어하려면 이를 사용하세요.", "selectDomainTypeCnameName": "단일 도메인 (CNAME)", "selectDomainTypeCnameDescription": "단일 하위 도메인 또는 특정 도메인 항목에 사용됩니다.", "selectDomainTypeWildcardName": "와일드카드 도메인", "selectDomainTypeWildcardDescription": "이 도메인 및 그 하위 도메인.", "domainDelegation": "단일 도메인", "selectType": "유형 선택", "actions": "작업", "refresh": "새로 고침", "refreshError": "데이터 새로고침 실패", "verified": "검증됨", "pending": "대기 중", "pendingApproval": "승인 대기 중", "sidebarBilling": "청구", "billing": "청구", "orgBillingDescription": "청구 정보 및 구독을 관리하세요", "github": "GitHub", "pangolinHosted": "판골린 호스팅", "fossorial": "지하 서식", "completeAccountSetup": "계정 설정 완료", "completeAccountSetupDescription": "시작하려면 비밀번호를 설정하세요", "accountSetupSent": "이 이메일 주소로 계정 설정 코드를 보내드리겠습니다.", "accountSetupCode": "설정 코드", "accountSetupCodeDescription": "설정 코드를 확인하기 위해 이메일을 확인하세요.", "passwordCreate": "비밀번호 생성", "passwordCreateConfirm": "비밀번호 확인", "accountSetupSubmit": "설정 코드 전송", "completeSetup": "설정 완료", "accountSetupSuccess": "계정 설정이 완료되었습니다! 판골린에 오신 것을 환영합니다!", "documentation": "문서", "saveAllSettings": "모든 설정 저장", "saveResourceTargets": "대상 저장", "saveResourceHttp": "프록시 설정 저장", "saveProxyProtocol": "프록시 프로토콜 설정 저장", "settingsUpdated": "설정이 업데이트되었습니다", "settingsUpdatedDescription": "설정이 성공적으로 업데이트되었습니다.", "settingsErrorUpdate": "설정 업데이트 실패", "settingsErrorUpdateDescription": "설정을 업데이트하는 동안 오류가 발생했습니다", "sidebarCollapse": "줄이기", "sidebarExpand": "확장하기", "productUpdateMoreInfo": "{noOfUpdates}개의 더 많은 업데이트", "productUpdateInfo": "{noOfUpdates}개 업데이트", "productUpdateWhatsNew": "새로운 기능", "productUpdateTitle": "제품 업데이트", "productUpdateEmpty": "업데이트 없음", "dismissAll": "모두 해제", "pangolinUpdateAvailable": "업데이트 가능", "pangolinUpdateAvailableInfo": "버전 {version}을(를) 설치할 준비가 되었습니다", "pangolinUpdateAvailableReleaseNotes": "릴리스 노트 보기", "newtUpdateAvailable": "업데이트 가능", "newtUpdateAvailableInfo": "뉴트의 새 버전이 출시되었습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "domainPickerEnterDomain": "도메인", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "리소스의 전체 도메인을 입력하여 사용 가능한 옵션을 확인하십시오.", "domainPickerDescriptionSaas": "전체 도메인, 서브도메인 또는 이름을 입력하여 사용 가능한 옵션을 확인하십시오.", "domainPickerTabAll": "모두", "domainPickerTabOrganization": "조직", "domainPickerTabProvided": "제공 됨", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "가용성을 확인 중...", "domainPickerNoMatchingDomains": "일치하는 도메인을 찾을 수 없습니다. 다른 도메인을 시도하거나 조직의 도메인 설정을 확인하십시오.", "domainPickerOrganizationDomains": "조직 도메인", "domainPickerProvidedDomains": "제공된 도메인", "domainPickerSubdomain": "서브도메인: {subdomain}", "domainPickerNamespace": "이름 공간: {namespace}", "domainPickerShowMore": "더보기", "regionSelectorTitle": "지역 선택", "regionSelectorInfo": "지역을 선택하면 위치에 따라 더 나은 성능이 제공됩니다. 서버와 같은 지역에 있을 필요는 없습니다.", "regionSelectorPlaceholder": "지역 선택", "regionSelectorComingSoon": "곧 출시 예정", "billingLoadingSubscription": "구독 불러오는 중...", "billingFreeTier": "무료 티어", "billingWarningOverLimit": "경고: 하나 이상의 사용 한도를 초과했습니다. 구독을 수정하거나 사용량을 조정하기 전까지 사이트는 연결되지 않습니다.", "billingUsageLimitsOverview": "사용 한도 개요", "billingMonitorUsage": "설정된 한도에 대한 사용량을 모니터링합니다. 한도를 늘려야 하는 경우 support@pangolin.net로 연락하십시오.", "billingDataUsage": "데이터 사용량", "billingSites": "사이트", "billingUsers": "사용자", "billingDomains": "도메인", "billingOrganizations": "조직", "billingRemoteExitNodes": "원격 노드", "billingNoLimitConfigured": "구성된 한도가 없습니다.", "billingEstimatedPeriod": "예상 청구 기간", "billingIncludedUsage": "포함 사용량", "billingIncludedUsageDescription": "현재 구독 계획에 포함된 사용량", "billingFreeTierIncludedUsage": "무료 티어 사용 허용량", "billingIncluded": "포함됨", "billingEstimatedTotal": "예상 총액:", "billingNotes": "노트", "billingEstimateNote": "현재 사용량을 기반으로 한 추정치입니다.", "billingActualChargesMayVary": "실제 청구 금액은 다를 수 있습니다.", "billingBilledAtEnd": "청구 기간이 끝난 후 청구됩니다.", "billingModifySubscription": "구독 수정", "billingStartSubscription": "구독 시작", "billingRecurringCharge": "반복 요금", "billingManageSubscriptionSettings": "구독 설정 및 기본 설정을 관리합니다", "billingNoActiveSubscription": "활성 구독이 없습니다. 사용 한도를 늘리려면 구독을 시작하십시오.", "billingFailedToLoadSubscription": "구독을 불러오는 데 실패했습니다.", "billingFailedToLoadUsage": "사용량을 불러오는 데 실패했습니다.", "billingFailedToGetCheckoutUrl": "체크아웃 URL을 가져오는 데 실패했습니다.", "billingPleaseTryAgainLater": "나중에 다시 시도하십시오.", "billingCheckoutError": "체크아웃 오류", "billingFailedToGetPortalUrl": "포털 URL을 가져오는 데 실패했습니다.", "billingPortalError": "포털 오류", "billingDataUsageInfo": "클라우드에 연결할 때 보안 터널을 통해 전송된 모든 데이터에 대해 비용이 청구됩니다. 여기에는 모든 사이트의 들어오고 나가는 트래픽이 포함됩니다. 사용량 한도에 도달하면 플랜을 업그레이드하거나 사용량을 줄일 때까지 사이트가 연결 해제됩니다. 노드를 사용하는 경우 데이터는 요금이 청구되지 않습니다.", "billingSInfo": "사용할 수 있는 사이트 수", "billingUsersInfo": "사용할 수 있는 사용자 수", "billingDomainInfo": "사용할 수 있는 도메인 수", "billingRemoteExitNodesInfo": "사용할 수 있는 원격 노드 수", "billingLicenseKeys": "라이센스 키", "billingLicenseKeysDescription": "라이센스 키 구독을 관리하세요", "billingLicenseSubscription": "라이센스 구독", "billingInactive": "비활성화됨", "billingLicenseItem": "라이센스 항목", "billingQuantity": "수량", "billingTotal": "총계", "billingModifyLicenses": "라이센스 구독 수정", "domainNotFound": "도메인을 찾을 수 없습니다", "domainNotFoundDescription": "이 리소스는 도메인이 더 이상 시스템에 존재하지 않아 비활성화되었습니다. 이 리소스에 대한 새 도메인을 설정하세요.", "failed": "실패", "createNewOrgDescription": "새 조직 생성", "organization": "조직", "primary": "기본", "port": "포트", "securityKeyManage": "보안 키 관리", "securityKeyDescription": "비밀번호 없는 인증을 위해 보안 키를 추가하거나 제거합니다.", "securityKeyRegister": "새 보안 키 등록", "securityKeyList": "귀하의 보안 키", "securityKeyNone": "등록된 보안 키가 아직 없습니다", "securityKeyNameRequired": "이름은 필수입니다", "securityKeyRemove": "제거", "securityKeyLastUsed": "마지막 사용: {date}", "securityKeyNameLabel": "보안 키 이름", "securityKeyRegisterSuccess": "보안 키가 성공적으로 등록되었습니다", "securityKeyRegisterError": "보안 키 등록 실패", "securityKeyRemoveSuccess": "보안 키가 성공적으로 제거되었습니다", "securityKeyRemoveError": "보안 키 제거 실패", "securityKeyLoadError": "보안 키를 불러오는 데 실패했습니다", "securityKeyLogin": "보안 키 사용", "securityKeyAuthError": "보안 키를 사용한 인증 실패", "securityKeyRecommendation": "항상 계정에 액세스할 수 있도록 다른 장치에 백업 보안 키를 등록하세요.", "registering": "등록 중...", "securityKeyPrompt": "보안 키를 사용하여 본인 확인을 진행하세요. 보안 키가 연결되어 사용 준비가 되었는지 확인하세요.", "securityKeyBrowserNotSupported": "귀하의 브라우저는 보안 키를 지원하지 않습니다. Chrome, Firefox, 또는 Safari와 같은 최신 브라우저를 사용하세요.", "securityKeyPermissionDenied": "로그인을 계속하려면 보안 키에 대한 액세스를 허용하세요.", "securityKeyRemovedTooQuickly": "로그인 프로세스가 완료될 때까지 보안 키를 연결 상태로 유지하세요.", "securityKeyNotSupported": "보안 키가 호환되지 않을 수 있습니다. 다른 보안 키를 사용해보세요.", "securityKeyUnknownError": "보안 키를 사용하는 데 문제가 발생했습니다. 다시 시도하세요.", "twoFactorRequired": "보안 키를 등록하려면 이중 인증이 필요합니다.", "twoFactor": "이중 인증", "twoFactorAuthentication": "이중 인증", "twoFactorDescription": "이 조직은 이중 인증을 요구합니다.", "enableTwoFactor": "이중 인증 활성화", "organizationSecurityPolicy": "조직 보안 정책", "organizationSecurityPolicyDescription": "이 조직에는 접근하기 전에 준수해야 하는 보안 요구 사항이 있습니다", "securityRequirements": "보안 요구 사항", "allRequirementsMet": "모든 요구 사항이 충족되었습니다", "completeRequirementsToContinue": "이 조직에 계속 접근하려면 아래 요구 사항을 완료하십시오", "youCanNowAccessOrganization": "이제 이 조직에 접근할 수 있습니다", "reauthenticationRequired": "세션 길이", "reauthenticationDescription": "이 조직은 {maxDays}일마다 로그인하는 것을 요구합니다.", "reauthenticationDescriptionHours": "이 조직은 {maxHours}시간마다 로그인하는 것을 요구합니다.", "reauthenticateNow": "다시 로그인", "adminEnabled2FaOnYourAccount": "관리자가 {email}에 대한 이중 인증을 활성화했습니다. 계속하려면 설정을 완료하세요.", "securityKeyAdd": "보안 키 추가", "securityKeyRegisterTitle": "새 보안 키 등록", "securityKeyRegisterDescription": "보안 키를 연결하고 식별할 이름을 입력하세요.", "securityKeyTwoFactorRequired": "이중 인증 필요", "securityKeyTwoFactorDescription": "보안 키를 등록하려면 이중 인증 코드를 입력하세요.", "securityKeyTwoFactorRemoveDescription": "보안 키를 제거하려면 이중 인증 코드를 입력하세요.", "securityKeyTwoFactorCode": "이중 인증 코드", "securityKeyRemoveTitle": "보안 키 삭제", "securityKeyRemoveDescription": "보안 키 \"{name}\"를 제거하려면 비밀번호를 입력하세요", "securityKeyNoKeysRegistered": "등록된 보안 키가 없습니다", "securityKeyNoKeysDescription": "계정 보안을 강화하려면 보안 키를 추가하세요.", "createDomainRequired": "도메인은 필수입니다", "createDomainAddDnsRecords": "DNS 레코드 추가", "createDomainAddDnsRecordsDescription": "설정을 완료하려면 도메인 제공자에게 다음 DNS 레코드를 추가하세요.", "createDomainNsRecords": "NS 레코드", "createDomainRecord": "레코드", "createDomainType": "유형:", "createDomainName": "이름:", "createDomainValue": "값:", "createDomainCnameRecords": "CNAME 레코드", "createDomainARecords": "A 레코드", "createDomainRecordNumber": "레코드 {number}", "createDomainTxtRecords": "TXT 레코드", "createDomainSaveTheseRecords": "이 레코드 저장", "createDomainSaveTheseRecordsDescription": "이 DNS 레코드를 저장하여 이후에 다시 볼 수 없습니다.", "createDomainDnsPropagation": "DNS 전파", "createDomainDnsPropagationDescription": "DNS 변경 사항은 인터넷 전체에 전파되는 데 시간이 걸립니다. DNS 제공자와 TTL 설정에 따라 몇 분에서 48시간까지 걸릴 수 있습니다.", "resourcePortRequired": "HTTP 리소스가 아닌 경우 포트 번호가 필요합니다", "resourcePortNotAllowed": "HTTP 리소스에 대해 포트 번호를 설정하지 마세요", "billingPricingCalculatorLink": "가격 계산기", "billingYourPlan": "귀하의 계획", "billingViewOrModifyPlan": "현재 계획 보기 또는 수정", "billingViewPlanDetails": "계획 세부정보 보기", "billingUsageAndLimits": "사용량 및 제한", "billingViewUsageAndLimits": "계획의 제한 및 현재 사용량 보기", "billingCurrentUsage": "현재 사용량", "billingMaximumLimits": "최대 제한", "billingRemoteNodes": "원격 노드", "billingUnlimited": "무제한", "billingPaidLicenseKeys": "유료 라이센스 키", "billingManageLicenseSubscription": "유료 독립 호스트 라이센스 키를 위한 구독 관리", "billingCurrentKeys": "현재 키", "billingModifyCurrentPlan": "현재 계획 수정", "billingConfirmUpgrade": "업그레이드 확인", "billingConfirmDowngrade": "다운그레이드 확인", "billingConfirmUpgradeDescription": "계획을 업그레이드하려고 합니다. 아래의 새로운 제한 및 가격을 검토하세요.", "billingConfirmDowngradeDescription": "계획을 다운그레이드하려고 합니다. 아래의 새로운 제한 및 가격을 검토하세요.", "billingPlanIncludes": "계획 포함", "billingProcessing": "처리 중...", "billingConfirmUpgradeButton": "업그레이드 확인", "billingConfirmDowngradeButton": "다운그레이드 확인", "billingLimitViolationWarning": "사용량이 새 계획의 제한을 초과합니다.", "billingLimitViolationDescription": "현재 사용량이 이 계획의 제한을 초과합니다. 다운그레이드 후 모든 작업은 새로운 제한 내로 사용량을 줄일 때까지 비활성화됩니다. 현재 초과된 제한 특징들을 검토하세요. 위반된 제한:", "billingFeatureLossWarning": "기능 가용성 알림", "billingFeatureLossDescription": "다운그레이드함으로써 새 계획에서 사용할 수 없는 기능은 자동으로 비활성화됩니다. 일부 설정 및 구성은 손실될 수 있습니다. 어떤 기능들이 더 이상 사용 불가능한지 이해하기 위해 가격표를 검토하세요.", "billingUsageExceedsLimit": "현재 사용량 ({current})이 제한 ({limit})을 초과합니다", "billingPastDueTitle": "연체된 결제", "billingPastDueDescription": "결제가 연체되었습니다. 현재 이용 중인 플랜 기능을 계속 사용하기 위해 결제 수단을 업데이트해 주세요. 해결되지 않으면 구독이 취소되고 무료 요금제로 전환됩니다.", "billingUnpaidTitle": "결제되지 않은 구독", "billingUnpaidDescription": "구독 결제가 완료되지 않아 무료 요금제로 전환되었습니다. 구독을 복원하려면 결제 수단을 업데이트해 주세요.", "billingIncompleteTitle": "불완전한 결제", "billingIncompleteDescription": "결제가 불완전합니다. 구독을 활성화하기 위해 결제 과정을 완료해 주세요.", "billingIncompleteExpiredTitle": "만료된 결제", "billingIncompleteExpiredDescription": "결제가 완료되지 않아 만료되었습니다. 무료 요금제로 전환되었습니다. 유료 기능에 대한 액세스를 복원하려면 다시 구독해 주세요.", "billingManageSubscription": "구독을 관리하십시오", "billingResolvePaymentIssue": "업그레이드 또는 다운그레이드하기 전에 결제 문제를 해결해 주세요.", "signUpTerms": { "IAgreeToThe": "동의합니다", "termsOfService": "서비스 약관", "and": "및", "privacyPolicy": "개인 정보 보호 정책." }, "signUpMarketing": { "keepMeInTheLoop": "이메일을 통해 소식, 업데이트 및 새로운 기능을 받아보세요." }, "siteRequired": "사이트가 필요합니다.", "olmTunnel": "Olm 터널", "olmTunnelDescription": "클라이언트 연결에 Olm 사용", "errorCreatingClient": "클라이언트 생성 오류", "clientDefaultsNotFound": "클라이언트 기본값을 찾을 수 없습니다.", "createClient": "클라이언트 생성", "createClientDescription": "개인 리소스에 액세스할 새 클라이언트를 생성하십시오", "seeAllClients": "모든 클라이언트 보기", "clientInformation": "클라이언트 정보", "clientNamePlaceholder": "클라이언트 이름", "address": "주소", "subnetPlaceholder": "서브넷", "addressDescription": "클라이언트의 내부 주소. 조직의 서브넷 내에 있어야 합니다.", "selectSites": "사이트 선택", "sitesDescription": "클라이언트는 선택한 사이트에 연결됩니다.", "clientInstallOlm": "Olm 설치", "clientInstallOlmDescription": "시스템에서 Olm을 실행하기", "clientOlmCredentials": "Olm 자격 증명", "clientOlmCredentialsDescription": "이것이 Newt가 서버와 인증하는 방법입니다", "olmEndpoint": "Olm 엔드포인트", "olmId": "ID", "olmSecretKey": "비밀", "clientCredentialsSave": "자격 증명 저장", "clientCredentialsSaveDescription": "이것은 한 번만 볼 수 있습니다. 안전한 장소에 복사해 두세요.", "generalSettingsDescription": "이 클라이언트에 대한 일반 설정을 구성하세요.", "clientUpdated": "클라이언트 업데이트됨", "clientUpdatedDescription": "클라이언트가 업데이트되었습니다.", "clientUpdateFailed": "클라이언트 업데이트 실패", "clientUpdateError": "클라이언트 업데이트 중 오류가 발생했습니다.", "sitesFetchFailed": "사이트 가져오기 실패", "sitesFetchError": "사이트 가져오는 중 오류가 발생했습니다.", "olmErrorFetchReleases": "Olm 릴리즈 가져오는 중 오류가 발생했습니다.", "olmErrorFetchLatest": "최신 Olm 릴리즈 가져오는 중 오류가 발생했습니다.", "enterCidrRange": "CIDR 범위 입력", "resourceEnableProxy": "공개 프록시 사용", "resourceEnableProxyDescription": "이 리소스에 대한 공개 프록시를 활성화하십시오. 이를 통해 네트워크 외부로부터 클라우드를 통해 열린 포트에서 리소스에 액세스할 수 있습니다. Traefik 구성이 필요합니다.", "externalProxyEnabled": "외부 프록시 활성화됨", "addNewTarget": "새 대상 추가", "targetsList": "대상 목록", "advancedMode": "고급 모드", "advancedSettings": "고급 설정", "targetErrorDuplicateTargetFound": "중복 대상 발견", "healthCheckHealthy": "정상", "healthCheckUnhealthy": "비정상", "healthCheckUnknown": "알 수 없음", "healthCheck": "상태 확인", "configureHealthCheck": "상태 확인 설정", "configureHealthCheckDescription": "{target}에 대한 상태 모니터링 설정", "enableHealthChecks": "상태 확인 활성화", "enableHealthChecksDescription": "이 대상을 모니터링하여 건강 상태를 확인하세요. 필요에 따라 대상과 다른 엔드포인트를 모니터링할 수 있습니다.", "healthScheme": "방법", "healthSelectScheme": "방법 선택", "healthCheckPortInvalid": "올바르지 않은 서브넷 마스크입니다. 1에서 65535 사이여야 합니다", "healthCheckPath": "경로", "healthHostname": "IP / 호스트", "healthPort": "포트", "healthCheckPathDescription": "상태 확인을 위한 경로입니다.", "healthyIntervalSeconds": "정상 간격(초)", "unhealthyIntervalSeconds": "비정상 간격(초)", "IntervalSeconds": "정상 간격", "timeoutSeconds": "타임아웃(초)", "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이 정상으로 간주됩니다.", "customHeaders": "사용자 정의 헤더", "customHeadersDescription": "헤더는 새 줄로 구분됨: Header-Name: value", "headersValidationError": "헤더는 형식이어야 합니다: 헤더명: 값.", "saveHealthCheck": "상태 확인 저장", "healthCheckSaved": "상태 확인이 저장되었습니다.", "healthCheckSavedDescription": "상태 확인 구성이 성공적으로 저장되었습니다", "healthCheckError": "상태 확인 오류", "healthCheckErrorDescription": "상태 확인 구성을 저장하는 동안 오류가 발생했습니다", "healthCheckPathRequired": "상태 확인 경로는 필수입니다.", "healthCheckMethodRequired": "HTTP 방법은 필수입니다.", "healthCheckIntervalMin": "확인 간격은 최소 5초여야 합니다.", "healthCheckTimeoutMin": "시간 초과는 최소 1초여야 합니다.", "healthCheckRetryMin": "재시도 횟수는 최소 1회여야 합니다.", "httpMethod": "HTTP 메소드", "selectHttpMethod": "HTTP 메소드 선택", "domainPickerSubdomainLabel": "서브도메인", "domainPickerBaseDomainLabel": "기본 도메인", "domainPickerSearchDomains": "도메인 검색...", "domainPickerNoDomainsFound": "찾을 수 없는 도메인이 없습니다", "domainPickerLoadingDomains": "도메인 로딩 중...", "domainPickerSelectBaseDomain": "기본 도메인 선택...", "domainPickerNotAvailableForCname": "CNAME 도메인에는 사용할 수 없습니다", "domainPickerEnterSubdomainOrLeaveBlank": "서브도메인을 입력하거나 기본 도메인을 사용하려면 공백으로 두십시오.", "domainPickerEnterSubdomainToSearch": "사용 가능한 무료 도메인에서 검색 및 선택할 서브도메인 입력.", "domainPickerFreeDomains": "무료 도메인", "domainPickerSearchForAvailableDomains": "사용 가능한 도메인 검색", "domainPickerNotWorkSelfHosted": "참고: 무료 제공 도메인은 현재 자체 호스팅 인스턴스에 사용할 수 없습니다.", "resourceDomain": "도메인", "resourceEditDomain": "도메인 수정", "siteName": "사이트 이름", "proxyPort": "포트", "resourcesTableProxyResources": "공유", "resourcesTableClientResources": "비공개", "resourcesTableNoProxyResourcesFound": "프록시 리소스를 찾을 수 없습니다.", "resourcesTableNoInternalResourcesFound": "내부 리소스를 찾을 수 없습니다.", "resourcesTableDestination": "대상지", "resourcesTableAlias": "별칭", "resourcesTableAliasAddress": "별칭 주소", "resourcesTableAliasAddressInfo": "이 주소는 조직의 유틸리티 서브넷의 일부로, 내부 DNS 해석을 사용하여 별칭 레코드를 해석하는 데 사용됩니다.", "resourcesTableClients": "클라이언트", "resourcesTableAndOnlyAccessibleInternally": "클라이언트와 연결되었을 때만 내부적으로 접근 가능합니다.", "resourcesTableNoTargets": "대상 없음", "resourcesTableHealthy": "정상", "resourcesTableDegraded": "저하됨", "resourcesTableOffline": "오프라인", "resourcesTableUnknown": "알 수 없음", "resourcesTableNotMonitored": "모니터링되지 않음", "editInternalResourceDialogEditClientResource": "비공개 리소스 수정", "editInternalResourceDialogUpdateResourceProperties": "{resourceName}의 리소스 속성과 대상 구성을 업데이트하세요", "editInternalResourceDialogResourceProperties": "리소스 속성", "editInternalResourceDialogName": "이름", "editInternalResourceDialogProtocol": "프로토콜", "editInternalResourceDialogSitePort": "사이트 포트", "editInternalResourceDialogTargetConfiguration": "대상 구성", "editInternalResourceDialogCancel": "취소", "editInternalResourceDialogSaveResource": "리소스 저장", "editInternalResourceDialogSuccess": "성공", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "내부 리소스가 성공적으로 업데이트되었습니다", "editInternalResourceDialogError": "오류", "editInternalResourceDialogFailedToUpdateInternalResource": "내부 리소스 업데이트 실패", "editInternalResourceDialogNameRequired": "이름은 필수입니다.", "editInternalResourceDialogNameMaxLength": "이름은 255자 이하이어야 합니다.", "editInternalResourceDialogProxyPortMin": "프록시 포트는 최소 1이어야 합니다.", "editInternalResourceDialogProxyPortMax": "프록시 포트는 65536 미만이어야 합니다.", "editInternalResourceDialogInvalidIPAddressFormat": "잘못된 IP 주소 형식", "editInternalResourceDialogDestinationPortMin": "대상 포트는 최소 1이어야 합니다.", "editInternalResourceDialogDestinationPortMax": "대상 포트는 65536 미만이어야 합니다.", "editInternalResourceDialogPortModeRequired": "포트 모드에는 프로토콜, 프록시 포트 및 대상 포트가 필요합니다", "editInternalResourceDialogMode": "모드", "editInternalResourceDialogModePort": "포트", "editInternalResourceDialogModeHost": "호스트", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "대상지", "editInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.", "editInternalResourceDialogDestinationIPDescription": "사이트 네트워크의 자원 IP 또는 호스트 네임 주소입니다.", "editInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.", "editInternalResourceDialogAlias": "별칭", "editInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.", "createInternalResourceDialogNoSitesAvailable": "사용 가능한 사이트가 없습니다.", "createInternalResourceDialogNoSitesAvailableDescription": "내부 리소스를 생성하려면 서브넷이 구성된 최소 하나의 Newt 사이트가 필요합니다.", "createInternalResourceDialogClose": "닫기", "createInternalResourceDialogCreateClientResource": "사이트 리소스 생성", "createInternalResourceDialogCreateClientResourceDescription": "선택한 사이트에 연결된 클라이언트에 접근할 새 리소스를 생성합니다", "createInternalResourceDialogResourceProperties": "리소스 속성", "createInternalResourceDialogName": "이름", "createInternalResourceDialogSite": "사이트", "selectSite": "사이트 선택...", "noSitesFound": "사이트를 찾을 수 없습니다.", "createInternalResourceDialogProtocol": "프로토콜", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "사이트 포트", "createInternalResourceDialogSitePortDescription": "사이트에 연결되었을 때 리소스에 접근하기 위해 이 포트를 사용합니다.", "createInternalResourceDialogTargetConfiguration": "대상 설정", "createInternalResourceDialogDestinationIPDescription": "사이트 네트워크의 자원 IP 또는 호스트 네임 주소입니다.", "createInternalResourceDialogDestinationPortDescription": "대상 IP에서 리소스에 접근할 수 있는 포트입니다.", "createInternalResourceDialogCancel": "취소", "createInternalResourceDialogCreateResource": "리소스 생성", "createInternalResourceDialogSuccess": "성공", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "내부 리소스가 성공적으로 생성되었습니다.", "createInternalResourceDialogError": "오류", "createInternalResourceDialogFailedToCreateInternalResource": "내부 리소스 생성 실패", "createInternalResourceDialogNameRequired": "이름은 필수입니다.", "createInternalResourceDialogNameMaxLength": "이름은 255자 이하이어야 합니다.", "createInternalResourceDialogPleaseSelectSite": "사이트를 선택하세요", "createInternalResourceDialogProxyPortMin": "프록시 포트는 최소 1이어야 합니다.", "createInternalResourceDialogProxyPortMax": "프록시 포트는 65536 미만이어야 합니다.", "createInternalResourceDialogInvalidIPAddressFormat": "잘못된 IP 주소 형식", "createInternalResourceDialogDestinationPortMin": "대상 포트는 최소 1이어야 합니다.", "createInternalResourceDialogDestinationPortMax": "대상 포트는 65536 미만이어야 합니다.", "createInternalResourceDialogPortModeRequired": "포트 모드에는 프로토콜, 프록시 포트 및 대상 포트가 필요합니다", "createInternalResourceDialogMode": "모드", "createInternalResourceDialogModePort": "포트", "createInternalResourceDialogModeHost": "호스트", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "대상지", "createInternalResourceDialogDestinationHostDescription": "사이트 네트워크의 자원 IP 주소입니다.", "createInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.", "createInternalResourceDialogAlias": "별칭", "createInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.", "siteConfiguration": "설정", "siteAcceptClientConnections": "클라이언트 연결 허용", "siteAcceptClientConnectionsDescription": "사용자 장치와 클라이언트가 이 사이트의 리소스에 접근할 수 있도록 허용하세요. 나중에 변경할 수 있습니다.", "siteAddress": "사이트 주소(고급)", "siteAddressDescription": "사이트의 내부 주소. 조직의 서브넷 내에 있어야 합니다.", "siteNameDescription": "나중에 변경할 수 있는 사이트의 표시 이름입니다.", "autoLoginExternalIdp": "외부 IDP로 자동 로그인", "autoLoginExternalIdpDescription": "인증을 위해 사용자를 외부 IDP로 즉시 리디렉션합니다.", "selectIdp": "IDP 선택", "selectIdpPlaceholder": "IDP 선택...", "selectIdpRequired": "자동 로그인이 활성화된 경우 IDP를 선택하십시오.", "autoLoginTitle": "리디렉션 중", "autoLoginDescription": "인증을 위해 외부 ID 공급자로 리디렉션 중입니다.", "autoLoginProcessing": "인증 준비 중...", "autoLoginRedirecting": "로그인으로 리디렉션 중...", "autoLoginError": "자동 로그인 오류", "autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.", "autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.", "remoteExitNodeManageRemoteExitNodes": "원격 노드", "remoteExitNodeDescription": "자체 원격 중계 및 프록시 서버 노드를 호스팅하십시오.", "remoteExitNodes": "노드", "searchRemoteExitNodes": "노드 검색...", "remoteExitNodeAdd": "노드 추가", "remoteExitNodeErrorDelete": "노드 삭제 오류", "remoteExitNodeQuestionRemove": "조직에서 노드를 제거하시겠습니까?", "remoteExitNodeMessageRemove": "한 번 제거되면 더 이상 노드에 접근할 수 없습니다.", "remoteExitNodeConfirmDelete": "노드 삭제 확인", "remoteExitNodeDelete": "노드 삭제", "sidebarRemoteExitNodes": "원격 노드", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "비밀", "remoteExitNodeCreate": { "title": "원격 노드 생성", "description": "새로운 자체 호스팅 원격 중계 및 프록시 서버 노드를 생성하십시오.", "viewAllButton": "모든 노드 보기", "strategy": { "title": "생성 전략", "description": "원격 노드를 생성하는 방법을 선택합니다.", "adopt": { "title": "노드 채택", "description": "이미 노드의 자격 증명이 있는 경우 이것을 선택하세요." }, "generate": { "title": "키 생성", "description": "노드에 대한 새 키를 생성하려면 이것을 선택하세요." } }, "adopt": { "title": "기존 노드 채택", "description": "채택하려는 기존 노드의 자격 증명을 입력하세요", "nodeIdLabel": "노드 ID", "nodeIdDescription": "채택하려는 기존 노드의 ID", "secretLabel": "비밀", "secretDescription": "기존 노드의 비밀 키", "submitButton": "노드 채택" }, "generate": { "title": "생성된 자격 증명", "description": "생성된 자격 증명을 사용하여 노드를 구성하세요", "nodeIdTitle": "노드 ID", "secretTitle": "비밀", "saveCredentialsTitle": "구성에 자격 증명 추가", "saveCredentialsDescription": "연결을 완료하려면 이러한 자격 증명을 자체 호스팅 Pangolin 노드 구성 파일에 추가하십시오.", "submitButton": "노드 생성" }, "validation": { "adoptRequired": "기존 노드를 채택하려면 노드 ID와 비밀 키가 필요합니다" }, "errors": { "loadDefaultsFailed": "기본값 로드 실패", "defaultsNotLoaded": "기본값 로드되지 않음", "createFailed": "노드 생성 실패" }, "success": { "created": "노드가 성공적으로 생성되었습니다" } }, "remoteExitNodeSelection": "노드 선택", "remoteExitNodeSelectionDescription": "이 로컬 사이트에서 트래픽을 라우팅할 노드를 선택하세요", "remoteExitNodeRequired": "로컬 사이트에 노드를 선택해야 합니다", "noRemoteExitNodesAvailable": "사용 가능한 노드가 없습니다", "noRemoteExitNodesAvailableDescription": "이 조직에 사용 가능한 노드가 없습니다. 로컬 사이트를 사용하려면 먼저 노드를 생성하세요.", "exitNode": "종단 노드", "country": "국가", "rulesMatchCountry": "현재 소스 IP를 기반으로 합니다", "managedSelfHosted": { "title": "관리 자체 호스팅", "description": "더 신뢰할 수 있고 낮은 유지보수의 자체 호스팅 팡골린 서버, 추가 기능 포함", "introTitle": "관리 자체 호스팅 팡골린", "introDescription": "는 자신의 데이터를 프라이빗하고 자체 호스팅을 유지하면서 더 간단하고 추가적인 신뢰성을 원하는 사람들을 위한 배포 옵션입니다.", "introDetail": "이 옵션을 사용하면 여전히 자신의 팡골린 노드를 운영하고 - 터널, SSL 종료 및 트래픽 모두 서버에 유지됩니다. 차이점은 관리 및 모니터링이 클라우드 대시보드를 통해 처리되어 여러 혜택을 제공합니다.", "benefitSimplerOperations": { "title": "더 간단한 운영", "description": "자체 메일 서버를 운영하거나 복잡한 경고를 설정할 필요가 없습니다. 기본적으로 상태 점검 및 다운타임 경고를 받을 수 있습니다." }, "benefitAutomaticUpdates": { "title": "자동 업데이트", "description": "클라우드 대시보드는 빠르게 발전하므로 새로운 기능과 버그 수정 사항을 수동으로 새로운 컨테이너를 가져오지 않고도 받을 수 있습니다." }, "benefitLessMaintenance": { "title": "유지보수 감소", "description": "데이터베이스 마이그레이션, 백업 또는 추가 인프라를 관리할 필요가 없습니다. 저희가 클라우드에서 처리합니다." }, "benefitCloudFailover": { "title": "클라우드 장애 조치", "description": "노드가 다운되면 터널이 클라우드의 프레즌스 포인트로 임시 전환되어 노드를 다시 온라인으로 가져올 때까지 유지됩니다." }, "benefitHighAvailability": { "title": "고가용성 (PoPs)", "description": "계정에 여러 노드를 연결하여 이중성과 성능을 향상시킬 수 있습니다." }, "benefitFutureEnhancements": { "title": "향후 개선", "description": "배포를 더욱 견고하게 만들기 위해 더 많은 분석, 경고, 및 관리 도구를 추가할 계획입니다." }, "docsAlert": { "text": "관리 자체 호스팅 옵션에 대해 더 알아보세요", "documentation": "문서" }, "convertButton": "이 노드를 관리 자체 호스팅으로 변환" }, "internationaldomaindetected": "국제 도메인 감지됨", "willbestoredas": "다음으로 저장됩니다:", "roleMappingDescription": "자동 프로비저닝이 활성화되면 사용자가 로그인할 때 역할이 할당되는 방법을 결정합니다.", "selectRole": "역할 선택", "roleMappingExpression": "표현식", "selectRolePlaceholder": "역할 선택", "selectRoleDescription": "이 신원 공급자로부터 모든 사용자에게 할당할 역할을 선택하십시오.", "roleMappingExpressionDescription": "ID 토큰에서 역할 정보를 추출하기 위한 JMESPath 표현식을 입력하세요.", "idpTenantIdRequired": "테넌트 ID가 필요합니다", "invalidValue": "잘못된 값", "idpTypeLabel": "신원 공급자 유형", "roleMappingExpressionPlaceholder": "예: contains(groups, 'admin') && 'Admin' || 'Member'", "idpGoogleConfiguration": "Google 구성", "idpGoogleConfigurationDescription": "Google OAuth2 자격 증명을 구성합니다.", "idpGoogleClientIdDescription": "Google OAuth2 클라이언트 ID", "idpGoogleClientSecretDescription": "Google OAuth2 클라이언트 비밀", "idpAzureConfiguration": "Azure Entra ID 구성", "idpAzureConfigurationDescription": "Azure Entra ID OAuth2 자격 증명을 구성합니다.", "idpTenantId": "테넌트 ID", "idpTenantIdPlaceholder": "테넌트 ID", "idpAzureTenantIdDescription": "Azure 액티브 디렉터리 개요에서 찾은 Azure 테넌트 ID", "idpAzureClientIdDescription": "Azure 앱 등록 클라이언트 ID", "idpAzureClientSecretDescription": "Azure 앱 등록 클라이언트 비밀", "idpGoogleTitle": "구글", "idpGoogleAlt": "구글", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "애저", "idpGoogleConfigurationTitle": "Google 구성", "idpAzureConfigurationTitle": "Azure Entra ID 구성", "idpTenantIdLabel": "테넌트 ID", "idpAzureClientIdDescription2": "Azure 앱 등록 클라이언트 ID", "idpAzureClientSecretDescription2": "Azure 앱 등록 클라이언트 비밀", "idpGoogleDescription": "Google OAuth2/OIDC 공급자", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자", "subnet": "서브넷", "subnetDescription": "이 조직의 네트워크 구성에 대한 서브넷입니다.", "customDomain": "사용자 정의 도메인", "authPage": "인증 페이지", "authPageDescription": "조직의 인증 페이지에 대한 사용자 정의 도메인을 설정하세요.", "authPageDomain": "인증 페이지 도메인", "authPageBranding": "사용자 정의 브랜딩", "authPageBrandingDescription": "이 조직의 인증 페이지에 표시될 브랜딩을 구성합니다.", "authPageBrandingUpdated": "인증 페이지 브랜딩이 성공적으로 업데이트되었습니다.", "authPageBrandingRemoved": "인증 페이지 브랜딩이 성공적으로 제거되었습니다.", "authPageBrandingRemoveTitle": "인증 페이지 브랜딩 제거", "authPageBrandingQuestionRemove": "인증 페이지의 브랜딩을 제거하시겠습니까?", "authPageBrandingDeleteConfirm": "브랜딩 삭제 확인", "brandingLogoURL": "로고 URL", "brandingLogoURLOrPath": "로고 URL 또는 경로", "brandingLogoPathDescription": "URL 또는 로컬 경로를 입력하세요.", "brandingLogoURLDescription": "로고 이미지에 대한 공용 URL을 입력하십시오.", "brandingPrimaryColor": "기본 색상", "brandingLogoWidth": "너비(px)", "brandingLogoHeight": "높이(px)", "brandingOrgTitle": "조직 인증 페이지의 제목", "brandingOrgDescription": "{orgName}은 조직의 이름으로 대체됩니다.", "brandingOrgSubtitle": "조직 인증 페이지의 부제목", "brandingResourceTitle": "리소스 인증 페이지의 제목", "brandingResourceSubtitle": "리소스 인증 페이지의 부제목", "brandingResourceDescription": "{resourceName} 은 조직의 이름으로 대체됩니다.", "saveAuthPageDomain": "도메인 저장", "saveAuthPageBranding": "브랜딩 저장", "removeAuthPageBranding": "브랜딩 제거", "noDomainSet": "도메인 설정 없음", "changeDomain": "도메인 변경", "selectDomain": "도메인 선택", "restartCertificate": "인증서 재시작", "editAuthPageDomain": "인증 페이지 도메인 편집", "setAuthPageDomain": "인증 페이지 도메인 설정", "failedToFetchCertificate": "인증서 가져오기 실패", "failedToRestartCertificate": "인증서 재시작 실패", "addDomainToEnableCustomAuthPages": "사용자는 이 도메인을 사용하여 조직의 로그인 페이지에 액세스하고 리소스 인증을 완료할 수 있습니다.", "selectDomainForOrgAuthPage": "조직 인증 페이지에 대한 도메인을 선택하세요.", "domainPickerProvidedDomain": "제공된 도메인", "domainPickerFreeProvidedDomain": "무료 제공된 도메인", "domainPickerVerified": "검증됨", "domainPickerUnverified": "검증되지 않음", "domainPickerInvalidSubdomainStructure": "이 하위 도메인은 잘못된 문자 또는 구조를 포함하고 있습니다. 저장 시 자동으로 정리됩니다.", "domainPickerError": "오류", "domainPickerErrorLoadDomains": "조직 도메인 로드 실패", "domainPickerErrorCheckAvailability": "도메인 가용성 확인 실패", "domainPickerInvalidSubdomain": "잘못된 하위 도메인", "domainPickerInvalidSubdomainRemoved": "입력 \"{sub}\"이(가) 유효하지 않으므로 제거되었습니다.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\"을(를) {domain}에 대해 유효하게 만들 수 없습니다.", "domainPickerSubdomainSanitized": "하위 도메인 정리됨", "domainPickerSubdomainCorrected": "\"{sub}\"이(가) \"{sanitized}\"로 수정되었습니다", "orgAuthSignInTitle": "조직 로그인", "orgAuthChooseIdpDescription": "계속하려면 신원 공급자를 선택하세요.", "orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.", "orgAuthSignInWithPangolin": "Pangolin으로 로그인", "orgAuthSignInToOrg": "조직에 로그인", "orgAuthSelectOrgTitle": "조직 로그인", "orgAuthSelectOrgDescription": "계속하려면 조직 ID를 입력하십시오.", "orgAuthOrgIdPlaceholder": "your-organization", "orgAuthOrgIdHelp": "조직의 고유 식별자를 입력하십시오.", "orgAuthSelectOrgHelp": "조직 ID를 입력하면, SSO 또는 조직 인증 정보를 사용할 수 있는 조직의 로그인 페이지로 이동합니다.", "orgAuthRememberOrgId": "이 조직 ID 기억하기", "orgAuthBackToSignIn": "표준 로그인을 통해 돌아가기", "orgAuthNoAccount": "계정이 없으신가요?", "subscriptionRequiredToUse": "이 기능을 사용하려면 구독이 필요합니다.", "mustUpgradeToUse": "이 기능을 사용하려면 구독을 업그레이드해야 합니다.", "subscriptionRequiredTierToUse": "이 기능을 사용하려면 {tier} 이상의 등급이 필요합니다.", "upgradeToTierToUse": "이 기능을 사용하려면 {tier} 이상으로 업그레이드하세요.", "subscriptionTierTier1": "홈", "subscriptionTierTier2": "팀", "subscriptionTierTier3": "비즈니스", "subscriptionTierEnterprise": "기업", "idpDisabled": "신원 공급자가 비활성화되었습니다.", "orgAuthPageDisabled": "조직 인증 페이지가 비활성화되었습니다.", "domainRestartedDescription": "도메인 인증이 성공적으로 재시작되었습니다.", "resourceAddEntrypointsEditFile": "파일 편집: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "파일 편집: docker-compose.yml", "emailVerificationRequired": "이메일 인증이 필요합니다. 이 단계를 완료하려면 {dashboardUrl}/auth/login 통해 다시 로그인하십시오. 그런 다음 여기로 돌아오세요.", "twoFactorSetupRequired": "이중 인증 설정이 필요합니다. 이 단계를 완료하려면 {dashboardUrl}/auth/login 통해 다시 로그인하십시오. 그런 다음 여기로 돌아오세요.", "additionalSecurityRequired": "추가 보안 필요", "organizationRequiresAdditionalSteps": "이 조직은 자원에 접근하기 전에 추가 보안 단계를 요구합니다.", "completeTheseSteps": "이 단계를 완료하십시오", "enableTwoFactorAuthentication": "이중 인증 활성화", "completeSecuritySteps": "보안 단계 완료", "securitySettings": "보안 설정", "dangerSection": "위험 구역", "dangerSectionDescription": "이 조직에 관련된 모든 데이터를 영구적으로 삭제합니다.", "securitySettingsDescription": "조직의 보안 정책을 구성하세요", "requireTwoFactorForAllUsers": "모든 사용자에 대해 이중 인증 요구", "requireTwoFactorDescription": "활성화되면, 이 조직의 모든 내부 사용자는 조직에 접근하기 위해 이중 인증을 활성화해야 합니다.", "requireTwoFactorDisabledDescription": "이 기능을 사용하려면 유효한 라이선스(Enterprise) 또는 활성 구독(SaaS)가 필요합니다.", "requireTwoFactorCannotEnableDescription": "모든 사용자에게 강제하기 전에 계정에 대해 이중 인증을 활성화해야 합니다", "maxSessionLength": "최대 세션 길이", "maxSessionLengthDescription": "사용자 세션의 최대 지속 시간을 설정합니다. 이 시간이 지나면 사용자는 다시 인증해야 합니다.", "maxSessionLengthDisabledDescription": "이 기능을 사용하려면 유효한 라이선스(Enterprise) 또는 활성 구독(SaaS)가 필요합니다.", "selectSessionLength": "세션 길이 선택", "unenforced": "강제되지 않음", "1Hour": "1 시간", "3Hours": "3 시간", "6Hours": "6 시간", "12Hours": "12 시간", "1DaySession": "1 일", "3Days": "3 일", "7Days": "7 일", "14Days": "14 일", "30DaysSession": "30 일", "90DaysSession": "90 일", "180DaysSession": "180 일", "passwordExpiryDays": "비밀번호 만료", "editPasswordExpiryDescription": "사용자가 비밀번호를 변경해야 하는 날 수를 설정합니다.", "selectPasswordExpiry": "비밀번호 만료 선택", "30Days": "30 일", "1Day": "1 일", "60Days": "60 일", "90Days": "90 일", "180Days": "180 일", "1Year": "1 년", "subscriptionBadge": "구독 필요", "securityPolicyChangeWarning": "보안 정책 변경 경고", "securityPolicyChangeDescription": "보안 정책 설정을 변경하려고 합니다. 저장 후, 정책 업데이트를 준수하기 위해 다시 인증해야 할 수도 있습니다. 규정을 준수하지 않는 모든 사용자도 다시 인증해야 합니다.", "securityPolicyChangeConfirmMessage": "확인합니다", "securityPolicyChangeWarningText": "이 작업은 조직의 모든 사용자에게 영향을 미칩니다", "authPageErrorUpdateMessage": "인증 페이지 설정을 업데이트하는 동안 오류가 발생했습니다", "authPageErrorUpdate": "인증 페이지를 업데이트할 수 없습니다", "authPageDomainUpdated": "인증 페이지 도메인이 성공적으로 업데이트되었습니다.", "healthCheckNotAvailable": "로컬", "rewritePath": "경로 재작성", "rewritePathDescription": "대상으로 전달하기 전에 경로를 선택적으로 재작성합니다.", "continueToApplication": "응용 프로그램으로 계속", "checkingInvite": "초대 확인 중", "setResourceHeaderAuth": "setResourceHeaderAuth", "resourceHeaderAuthRemove": "헤더 인증 제거", "resourceHeaderAuthRemoveDescription": "헤더 인증이 성공적으로 제거되었습니다.", "resourceErrorHeaderAuthRemove": "헤더 인증 제거 실패", "resourceErrorHeaderAuthRemoveDescription": "리소스의 헤더 인증을 제거할 수 없습니다.", "resourceHeaderAuthProtectionEnabled": "헤더 인증 활성화됨", "resourceHeaderAuthProtectionDisabled": "헤더 인증 비활성화됨", "headerAuthRemove": "헤더 인증 삭제", "headerAuthAdd": "헤더 인증 추가", "resourceErrorHeaderAuthSetup": "헤더 인증 설정 실패", "resourceErrorHeaderAuthSetupDescription": "리소스의 헤더 인증을 설정할 수 없습니다.", "resourceHeaderAuthSetup": "헤더 인증이 성공적으로 설정되었습니다.", "resourceHeaderAuthSetupDescription": "헤더 인증이 성공적으로 설정되었습니다.", "resourceHeaderAuthSetupTitle": "헤더 인증 설정", "resourceHeaderAuthSetupTitleDescription": "이 리소스를 HTTP 헤더 인증으로 보호하기 위해 기본 인증 자격 증명(사용자이름 및 비밀번호)을 설정합니다. 다음과 같은 형식으로 액세스하세요 https://사용자이름:비밀번호@resource.example.com", "resourceHeaderAuthSubmit": "헤더 인증 설정", "actionSetResourceHeaderAuth": "헤더 인증 설정", "enterpriseEdition": "엔터프라이즈 에디션", "unlicensed": "라이선스 없음", "beta": "베타", "manageUserDevices": "사용자 초대를 제어", "manageUserDevicesDescription": "리소스에 개인적으로 연결하기 위해 사용자가 사용하는 장치를 보고 관리하세요", "downloadClientBannerTitle": "Pangolin 클라이언트 다운로드", "downloadClientBannerDescription": "Pangolin 네트워크에 연결하고 리소스를 비공개로 접근하기위해 시스템에 맞는 Pangolin 클라이언트를 다운로드하십시오.", "manageMachineClients": "기계 클라이언트 관리", "manageMachineClientsDescription": "서버와 시스템이 리소스에 개인적으로 연결하는 데 사용하는 클라이언트를 생성하고 관리하십시오", "machineClientsBannerTitle": "서버 및 자동 시스템", "machineClientsBannerDescription": "머신 클라이언트는 특정 사용자와 연결되지 않은 서버 및 자동화된 시스템을 위한 것입니다. 이들은 ID와 비밀을 통해 인증하며, Pangolin CLI, Olm CLI, 또는 Olm 컨테이너로 실행될 수 있습니다.", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Olm 컨테이너", "clientsTableUserClients": "사용자", "clientsTableMachineClients": "기계", "licenseTableValidUntil": "유효 기한", "saasLicenseKeysSettingsTitle": "엔터프라이즈 라이선스", "saasLicenseKeysSettingsDescription": "자체 호스팅된 Pangolin 인스턴스를 위한 엔터프라이즈 라이선스 키를 생성하고 관리합니다.", "sidebarEnterpriseLicenses": "라이선스", "generateLicenseKey": "라이선스 키 생성", "generateLicenseKeyForm": { "validation": { "emailRequired": "유효한 이메일 주소를 입력하세요", "useCaseTypeRequired": "사용 사례 유형을 선택하세요", "firstNameRequired": "이름은 필수입니다.", "lastNameRequired": "성은 필수입니다.", "primaryUseRequired": "주요 용도를 설명하세요", "jobTitleRequiredBusiness": "사업용 직책은 필수입니다.", "industryRequiredBusiness": "사업용 산업은 필수입니다.", "stateProvinceRegionRequired": "주/도/지역이 필수입니다.", "postalZipCodeRequired": "우편번호/ZIP 코드가 필수입니다.", "companyNameRequiredBusiness": "사업용 회사 이름은 필수입니다.", "countryOfResidenceRequiredBusiness": "사업용 거주 국가는 필수입니다.", "countryRequiredPersonal": "개인용 국가가 필수입니다.", "agreeToTermsRequired": "약관에 동의해야 합니다.", "complianceConfirmationRequired": "Fossorial 상용 라이선스를 준수함을 확인해야 합니다." }, "useCaseOptions": { "personal": { "title": "개인 용도", "description": "학습, 개인 프로젝트 또는 실험과 같은 개인, 비상업적 용도로 사용합니다." }, "business": { "title": "사업 용도", "description": "조직, 회사 또는 수익 창출 활동 내에서 사용됩니다." } }, "steps": { "emailLicenseType": { "title": "이메일 및 라이선스 유형", "description": "이메일을 입력하고 라이선스 유형을 선택하세요" }, "personalInformation": { "title": "개인 정보", "description": "자기소개를 해주세요" }, "contactInformation": { "title": "연락처 정보", "description": "당신의 연락처 정보" }, "termsGenerate": { "title": "약관 및 생성", "description": "라이선스를 생성하기 위해 약관을 검토하고 수락하세요" } }, "alerts": { "commercialUseDisclosure": { "title": "사용 공개", "description": "당신의 의도된 사용에 정확히 맞는 라이선스 등급을 선택하세요. 개인 라이선스는 연간 총 수익 100,000 USD 이하의 개인, 비상업적 또는 소규모 상업 활동을 위한 소프트웨어의 무료 사용을 허용합니다. 이러한 제한을 넘는 모든 사용 — 비즈니스, 조직 또는 기타 수익 창출 환경 내에서의 사용 — 은 유효한 엔터프라이즈 라이선스 및 해당 라이선스 수수료의 지불이 필요합니다. 개인 또는 기업 사용자는 모두 Fossorial 상용 라이선스 조건을 준수해야 합니다." }, "trialPeriodInformation": { "title": "시험 기간 정보", "description": "이 라이선스 키는 엔터프라이즈 기능을 평가판 기간 동안 7일 동안 활성화합니다. 평가 기간 이후 유료 기능에 대한 계속된 액세스는 유효한 개인 또는 엔터프라이즈 라이선스 하에서 활성화가 필요합니다. 엔터프라이즈 라이선스에 대한 정보는 sales@pangolin.net에 문의하세요." } }, "form": { "useCaseQuestion": "개인 또는 사업용으로 Pangolin을 사용하시나요?", "firstName": "이름", "lastName": "성", "jobTitle": "직책", "primaryUseQuestion": "Pangolin을 주로 무엇에 사용하려고 계획하시나요?", "industryQuestion": "당신의 산업은 무엇입니까?", "prospectiveUsersQuestion": "예상하는 잠재적 사용자는 몇 명입니까?", "prospectiveSitesQuestion": "예상하는 잠재적 사이트(터널)는 몇 개입니까?", "companyName": "회사 이름", "countryOfResidence": "거주 국가", "stateProvinceRegion": "주 / 도 / 지역", "postalZipCode": "우편번호 / ZIP 코드", "companyWebsite": "회사 웹사이트", "companyPhoneNumber": "회사 전화번호", "country": "국가", "phoneNumberOptional": "전화번호 (선택 항목)", "complianceConfirmation": "제가 제공한 정보가 정확하고 Fossorial 상용 라이선스를 준수하는지 확인합니다. 부정확한 정보를 보고하거나 제품 사용을 실수로 식별하는 것은 라이선스 위반이며, 키가 취소될 수 있습니다." }, "buttons": { "close": "닫기", "previous": "이전", "next": "다음", "generateLicenseKey": "라이선스 키 생성" }, "toasts": { "success": { "title": "라이선스 키가 성공적으로 생성되었습니다", "description": "당신의 라이선스 키가 생성되었으며 사용 준비가 되었습니다." }, "error": { "title": "라이선스 키 생성에 실패했습니다", "description": "라이선스 키를 생성하는 동안 오류가 발생했습니다." } } }, "newPricingLicenseForm": { "title": "라이센스 가져오기", "description": "계획을 선택하고 Pangolin을 어떻게 사용할지 알려주세요.", "chooseTier": "계획 선택", "viewPricingLink": "가격, 기능 및 제한 보기", "tiers": { "starter": { "title": "스타터", "description": "기업 기능, 25명의 사용자, 25개의 사이트, 커뮤니티 지원." }, "scale": { "title": "스케일", "description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원." } }, "personalUseOnly": "개인 사용 전용 (무료 라이센스 — 체크아웃 없음)", "buttons": { "continueToCheckout": "결제로 진행" }, "toasts": { "checkoutError": { "title": "체크아웃 오류", "description": "체크아웃을 시작할 수 없습니다. 다시 시도하세요." } } }, "priority": "우선순위", "priorityDescription": "우선 순위가 높은 경로가 먼저 평가됩니다. 우선 순위 = 100은 자동 정렬(시스템 결정)이 의미합니다. 수동 우선 순위를 적용하려면 다른 숫자를 사용하세요.", "instanceName": "인스턴스 이름", "pathMatchModalTitle": "경로 매칭 설정", "pathMatchModalDescription": "경로별로 들어오는 요청을 어떻게 매칭할지 설정합니다.", "pathMatchType": "일치 유형", "pathMatchPrefix": "접두사", "pathMatchExact": "정확하게", "pathMatchRegex": "정규 표현식", "pathMatchValue": "경로 값", "clear": "지우기", "saveChanges": "변경 사항 저장", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/경로", "pathMatchPrefixHelp": "예: /api 는 /api, /api/users 등을 매칭합니다.", "pathMatchExactHelp": "예: /api 는 /api 만 매칭합니다.", "pathMatchRegexHelp": "예: ^/api/.* 는 /api/anything를 매칭합니다", "pathRewriteModalTitle": "경로 재작성 설정", "pathRewriteModalDescription": "대상으로 전달하기 전에 매칭된 경로를 변환합니다.", "pathRewriteType": "재작성 유형", "pathRewritePrefixOption": "접두사 - 접두사 대체", "pathRewriteExactOption": "정확 - 전체 경로 대체", "pathRewriteRegexOption": "정규 표현식 - 패턴 대체", "pathRewriteStripPrefixOption": "접두사 제거 - 접두사 삭제", "pathRewriteValue": "재작성 값", "pathRewriteRegexPlaceholder": "/new/$1", "pathRewriteDefaultPlaceholder": "/새-경로", "pathRewritePrefixHelp": "일치하는 접두사를 이 값으로 대체합니다", "pathRewriteExactHelp": "경로가 정확히 일치할 때 전체 경로를 이 값으로 대체합니다", "pathRewriteRegexHelp": "$1, $2와 같은 캡처 그룹을 사용하여 대체", "pathRewriteStripPrefixHelp": "접두사를 제거하거나 새 접두사를 제공하려면 비워 둡니다", "pathRewritePrefix": "접두사", "pathRewriteExact": "정확하게", "pathRewriteRegex": "정규 표현식", "pathRewriteStrip": "제거", "pathRewriteStripLabel": "제거", "sidebarEnableEnterpriseLicense": "엔터프라이즈 라이선스 활성화", "cannotbeUndone": "이 작업은 되돌릴 수 없습니다.", "toConfirm": "확인을 위해.", "deleteClientQuestion": "고객을 사이트와 조직에서 제거하시겠습니까?", "clientMessageRemove": "제거되면 클라이언트는 사이트에 더 이상 연결할 수 없습니다.", "sidebarLogs": "로그", "request": "요청", "requests": "요청", "logs": "로그", "logsSettingsDescription": "이 조직에서 수집된 로그를 모니터링합니다.", "searchLogs": "로그 검색...", "action": "작업", "actor": "행위자", "timestamp": "타임스탬프", "accessLogs": "접근 로그", "exportCsv": "CSV 내보내기", "exportError": "CSV로 내보내는 중 알 수 없는 오류가 발생했습니다.", "exportCsvTooltip": "시간 범위 내", "actorId": "행위자 ID", "allowedByRule": "룰에 의해 허용됨", "allowedNoAuth": "인증 없음 허용됨", "validAccessToken": "유효한 접근 토큰", "validHeaderAuth": "유효한 헤더 인증", "validPincode": "유효한 핀코드", "validPassword": "유효한 비밀번호", "validEmail": "유효한 이메일", "validSSO": "유효한 SSO", "resourceBlocked": "리소스 차단됨", "droppedByRule": "룰에 의해 드롭됨", "noSessions": "세션 없음", "temporaryRequestToken": "임시 요청 토큰", "noMoreAuthMethods": "유효한 인증 없음", "ip": "IP", "reason": "이유", "requestLogs": "요청 로그", "requestAnalytics": "요청 분석", "host": "호스트", "location": "위치", "actionLogs": "작업 로그", "sidebarLogsRequest": "요청 로그", "sidebarLogsAccess": "접근 로그", "sidebarLogsAction": "작업 로그", "logRetention": "로그 보관", "logRetentionDescription": "다양한 유형의 로그를 이 조직에 대해 얼마나 오래 보관할지 관리하거나 비활성화합니다", "requestLogsDescription": "이 조직의 자원에 대한 상세한 요청 로그를 봅니다", "requestAnalyticsDescription": "이 조직의 리소스에 대한 자세한 요청 분석 보기", "logRetentionRequestLabel": "요청 로그 보관", "logRetentionRequestDescription": "요청 로그를 얼마나 오래 보관할지", "logRetentionAccessLabel": "접근 로그 보관", "logRetentionAccessDescription": "접근 로그를 얼마나 오래 보관할지", "logRetentionActionLabel": "작업 로그 보관", "logRetentionActionDescription": "작업 로그를 얼마나 오래 보관할지", "logRetentionDisabled": "비활성화됨", "logRetention3Days": "3 일", "logRetention7Days": "7 일", "logRetention14Days": "14 일", "logRetention30Days": "30 일", "logRetention90Days": "90 일", "logRetentionForever": "영구", "logRetentionEndOfFollowingYear": "다음 연도 말", "actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다", "accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다", "licenseRequiredToUse": "이 기능을 사용하려면 엔터프라이즈 에디션 라이선스가 필요합니다. 이 기능은 판골린 클라우드에서도 사용할 수 있습니다.", "ossEnterpriseEditionRequired": "이 기능을 사용하려면 엔터프라이즈 에디션이 필요합니다. 이 기능은 판골린 클라우드에서도 사용할 수 있습니다.", "certResolver": "인증서 해결사", "certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.", "selectCertResolver": "인증서 해결사 선택", "enterCustomResolver": "사용자 정의 해결사 입력", "preferWildcardCert": "와일드카드 인증서 선호", "unverified": "검증되지 않음", "domainSetting": "도메인 설정", "domainSettingDescription": "도메인 설정 구성", "preferWildcardCertDescription": "와일드카드 인증서를 생성하려고 시도합니다(적절한 인증서 해결 장치가 필요).", "recordName": "레코드 이름", "auto": "자동", "TTL": "TTL", "howToAddRecords": "레코드 추가 방법", "dnsRecord": "DNS 레코드", "required": "필수", "domainSettingsUpdated": "도메인 설정이 성공적으로 업데이트되었습니다", "orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다", "loadingDNSRecords": "DNS 레코드를 로드하는 중...", "olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "client": "클라이언트", "proxyProtocol": "프록시 프로토콜 설정", "proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.", "enableProxyProtocol": "프록시 프로토콜 활성화", "proxyProtocolInfo": "TCP 백엔드에 대한 클라이언트 IP 주소를 유지합니다.", "proxyProtocolVersion": "프록시 프로토콜 버전", "version1": " 버전 1 (추천)", "version2": "버전 2", "versionDescription": "버전 1은 텍스트 기반으로 널리 지원됩니다. 버전 2는 이진 기반으로 더 효율적이지만 호환성이 낮습니다.", "warning": "경고", "proxyProtocolWarning": "백엔드 애플리케이션이 프록시 프로토콜 연결을 허용하도록 구성되어야 합니다. 백엔드가 프록시 프로토콜을 지원하지 않으면, 이를 활성화하면 모든 연결이 끊어집니다. 트래픽에서 온 프록시 프로토콜 헤더를 백엔드가 신뢰하도록 구성하십시오.", "restarting": "재시작 중...", "manual": "수동", "messageSupport": "지원 메시지", "supportNotAvailableTitle": "지원 불가", "supportNotAvailableDescription": "현재 지원을 받을 수 없습니다. support@pangolin.net으로 이메일을 보낼 수 있습니다.", "supportRequestSentTitle": "지원 요청 전송 완료", "supportRequestSentDescription": "메시지가 성공적으로 전송되었습니다.", "supportRequestFailedTitle": "요청 전송 실패", "supportRequestFailedDescription": "지원 요청을 보내는 중 오류가 발생했습니다.", "supportSubjectRequired": "제목은 필수입니다", "supportSubjectMaxLength": "제목은 255자 이내여야 합니다", "supportMessageRequired": "메시지는 필수입니다", "supportReplyTo": "회신", "supportSubject": "제목", "supportSubjectPlaceholder": "제목 입력", "supportMessage": "메시지", "supportMessagePlaceholder": "메시지를 입력하십시오", "supportSending": "발송 중...", "supportSend": "보내기", "supportMessageSent": "메시지 전송 완료!", "supportWillContact": "곧 연락드리겠습니다!", "selectLogRetention": "로그 보존 선택", "terms": "약관", "privacy": "개인정보 보호", "security": "보안", "docs": "문서", "deviceActivation": "장치 활성화", "deviceCodeInvalidFormat": "코드는 9자리여야 합니다 (예: A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "무효하거나 만료된 코드", "deviceCodeVerifyFailed": "이메일 확인에 실패했습니다:", "deviceCodeValidating": "장치 코드 검증 중...", "deviceCodeVerifying": "장치 권한 검증 중...", "signedInAs": "로그인한 사용자", "deviceCodeEnterPrompt": "기기에 표시된 코드를 입력하세요", "continue": "계속 진행하기", "deviceUnknownLocation": "알 수 없는 위치", "deviceAuthorizationRequested": "이 인증 요청은 {location}에서 {date}에 요청되었습니다. 이 장치에 액세스 권한을 부여할 신뢰할 수 있는 경우 확인하세요.", "deviceLabel": "장치: {deviceName}", "deviceWantsAccess": "계정에 액세스하려고 합니다", "deviceExistingAccess": "기존 액세스:", "deviceFullAccess": "계정에 대한 전체 액세스", "deviceOrganizationsAccess": "계정이 접근할 수 있는 모든 조직에 대한 접근", "deviceAuthorize": "{applicationName} 권한 부여", "deviceConnected": "장치가 연결되었습니다!", "deviceAuthorizedMessage": "장치가 계정 접속을 승인받았습니다. 클라이언트 응용프로그램으로 돌아가세요.", "pangolinCloud": "판골린 클라우드", "viewDevices": "장치 보기", "viewDevicesDescription": "연결된 장치를 관리하십시오", "noDevices": "장치를 찾을 수 없습니다", "dateCreated": "생성 날짜", "unnamedDevice": "이름 없는 장치", "deviceQuestionRemove": "이 장치를 삭제하시겠습니까?", "deviceMessageRemove": "이 작업은 취소할 수 없습니다.", "deviceDeleteConfirm": "장치 삭제", "deleteDevice": "장치 삭제", "errorLoadingDevices": "장치 로딩 오류", "failedToLoadDevices": "장치를 로드하지 못했습니다", "deviceDeleted": "장치 삭제 완료", "deviceDeletedDescription": "장치가 성공적으로 삭제되었습니다.", "errorDeletingDevice": "장치 삭제 오류", "failedToDeleteDevice": "장치를 삭제하지 못했습니다", "showColumns": "열 표시", "hideColumns": "열 숨기기", "columnVisibility": "열 가시성", "toggleColumn": "{columnName} 열 토글", "allColumns": "모든 열", "defaultColumns": "기본 열", "customizeView": "보기 사용자 지정", "viewOptions": "보기 옵션", "selectAll": "모두 선택", "selectNone": "선택하지 않음", "selectedResources": "선택된 리소스", "enableSelected": "선택된 항목 활성화", "disableSelected": "선택된 항목 비활성화", "checkSelectedStatus": "선택된 항목 상태 확인", "clients": "클라이언트", "accessClientSelect": "기계 클라이언트 선택", "resourceClientDescription": "관리자는 항상 이 리소스에 접근할 수 있습니다", "regenerate": "재생성", "credentials": "자격 증명", "savecredentials": "자격 증명 저장", "regenerateCredentialsButton": "자격 증명 다시 생성", "regenerateCredentials": "자격 증명 다시 생성", "generatedcredentials": "생성된 자격 증명", "copyandsavethesecredentials": "이 자격 증명을 복사하여 저장합니다", "copyandsavethesecredentialsdescription": "이 페이지를 떠난 후에는 자격 증명이 다시 표시되지 않습니다. 지금 안전하게 저장하십시오.", "credentialsSaved": "자격 증명 저장됨", "credentialsSavedDescription": "자격 증명이 성공적으로 재생성 및 저장되었습니다.", "credentialsSaveError": "자격 증명 저장 오류", "credentialsSaveErrorDescription": "자격 증명을 재생성하고 저장하는 동안 오류가 발생했습니다.", "regenerateCredentialsWarning": "자격 증명을 다시 생성하면 이전 것들이 무효화되면서 연결이 끊어집니다. 이러한 자격 증명을 사용하는 모든 구성을 업데이트하세요.", "confirm": "확인", "regenerateCredentialsConfirmation": "자격 증명을 재생성하시겠습니까?", "endpoint": "엔드포인트", "Id": "아이디", "SecretKey": "비밀 키", "niceId": "예쁜 ID", "niceIdUpdated": "예쁜 ID 업데이트됨", "niceIdUpdatedSuccessfully": "예쁜 ID가 성공적으로 업데이트되었습니다", "niceIdUpdateError": "예쁜 ID 업데이트 오류", "niceIdUpdateErrorDescription": "예쁜 ID를 업데이트하는 동안 오류가 발생했습니다.", "niceIdCannotBeEmpty": "예쁜 ID는 비워둘 수 없습니다", "enterIdentifier": "식별자 입력", "identifier": "식별자", "deviceLoginUseDifferentAccount": "본인이 아닙니까? 다른 계정을 사용하세요.", "deviceLoginDeviceRequestingAccessToAccount": "장치가 이 계정에 접근하려고 합니다.", "loginSelectAuthenticationMethod": "계속하려면 인증 방법을 선택하세요.", "noData": "데이터 없음", "machineClients": "기계 클라이언트", "install": "설치", "run": "실행", "clientNameDescription": "나중에 변경할 수 있는 클라이언트의 표시 이름입니다.", "clientAddress": "클라이언트 주소(고급)", "setupFailedToFetchSubnet": "기본값 로드 실패", "setupSubnetAdvanced": "서브넷(고급)", "setupSubnetDescription": "이 조직의 네트워크 구성에 대한 서브넷입니다.", "setupUtilitySubnet": "유틸리티 서브넷 (고급)", "setupUtilitySubnetDescription": "이 조직의 별칭 주소 및 DNS 서버에 대한 서브넷입니다.", "siteRegenerateAndDisconnect": "재생성 및 연결 해제", "siteRegenerateAndDisconnectConfirmation": "자격 증명을 재생성하고 이 사이트와의 연결을 해제하시겠습니까?", "siteRegenerateAndDisconnectWarning": "이 과정은 자격 증명을 다시 생성하고 사이트와의 연결을 즉시 해제합니다. 사이트는 새 자격 증명으로 다시 시작되어야 합니다.", "siteRegenerateCredentialsConfirmation": "이 사이트에 대한 자격 증명을 다시 생성하시겠습니까?", "siteRegenerateCredentialsWarning": "이 과정은 자격 증명을 다시 생성합니다. 수동으로 다시 시작하고 새 자격 증명을 사용하기 전까지 사이트는 연결된 상태로 유지됩니다.", "clientRegenerateAndDisconnect": "재생성 및 연결 해제", "clientRegenerateAndDisconnectConfirmation": "자격 증명을 재생성하고 이 클라이언트와의 연결을 해제하시겠습니까?", "clientRegenerateAndDisconnectWarning": "이 과정은 자격 증명을 다시 생성하고 클라이언트와의 연결을 즉시 해제합니다. 클라이언트는 새 자격 증명으로 다시 시작되어야 합니다.", "clientRegenerateCredentialsConfirmation": "이 클라이언트에 대한 자격 증명을 다시 생성하시겠습니까?", "clientRegenerateCredentialsWarning": "이 과정은 자격 증명을 다시 생성합니다. 수동으로 다시 시작하고 새 자격 증명을 사용하기 전까지 클라이언트는 연결된 상태로 유지됩니다.", "remoteExitNodeRegenerateAndDisconnect": "재생성 및 연결 해제", "remoteExitNodeRegenerateAndDisconnectConfirmation": "자격 증명을 재생성하고 이 원격 종료 노드와의 연결을 해제하시겠습니까?", "remoteExitNodeRegenerateAndDisconnectWarning": "이 과정은 자격 증명을 다시 생성하고 원격 종료 노드와의 연결을 즉시 해제합니다. 원격 종료 노드는 새 자격 증명으로 다시 시작되어야 합니다.", "remoteExitNodeRegenerateCredentialsConfirmation": "이 원격 종료 노드에 대한 자격 증명을 다시 생성하시겠습니까?", "remoteExitNodeRegenerateCredentialsWarning": "이 과정은 자격 증명을 다시 생성합니다. 수동으로 다시 시작하고 새 자격 증명을 사용하기 전까지 원격 종료 노드는 연결된 상태로 유지됩니다.", "agent": "에이전트", "personalUseOnly": "개인 용도로만 사용", "loginPageLicenseWatermark": "이 인스턴스는 개인 용도로만 라이선스가 부여되었습니다.", "instanceIsUnlicensed": "이 인스턴스에는 라이선스가 없습니다.", "portRestrictions": "포트 제한", "allPorts": "모두", "custom": "사용자 정의", "allPortsAllowed": "모든 포트 허용", "allPortsBlocked": "모든 포트 차단", "tcpPortsDescription": "이 리소스에 허용된 TCP 포트를 지정하세요. 모든 포트에 '*'를 사용하고, 모든 포트를 차단하려면 비워두거나 쉼표로 구분된 포트 및 범위 목록(예: 80,443,8000-9000)을 입력하십시오.", "udpPortsDescription": "이 리소스에 허용된 UDP 포트를 지정하세요. 모든 포트에 '*'를 사용하고, 모든 포트를 차단하려면 비워두거나 쉼표로 구분된 포트 및 범위 목록(예: 53,123,500-600)을 입력하십시오.", "organizationLoginPageTitle": "조직 로그인 페이지", "organizationLoginPageDescription": "이 조직의 로그인 페이지를 사용자 정의합니다.", "resourceLoginPageTitle": "리소스 로그인 페이지", "resourceLoginPageDescription": "각 리소스의 로그인 페이지를 사용자 정의합니다.", "enterConfirmation": "확인 입력", "blueprintViewDetails": "세부 정보", "defaultIdentityProvider": "기본 아이덴티티 공급자", "defaultIdentityProviderDescription": "기본 ID 공급자가 선택되면, 사용자는 인증을 위해 자동으로 해당 공급자로 리디렉션됩니다.", "editInternalResourceDialogNetworkSettings": "네트워크 설정", "editInternalResourceDialogAccessPolicy": "액세스 정책", "editInternalResourceDialogAddRoles": "역할 추가", "editInternalResourceDialogAddUsers": "사용자 추가", "editInternalResourceDialogAddClients": "클라이언트 추가", "editInternalResourceDialogDestinationLabel": "대상지", "editInternalResourceDialogDestinationDescription": "내부 리소스의 목적지 주소를 지정하세요. 선택한 모드에 따라 이 주소는 호스트명, IP 주소, 또는 CIDR 범위가 될 수 있습니다. 더욱 쉽게 식별할 수 있도록 내부 DNS 별칭을 설정할 수 있습니다.", "editInternalResourceDialogPortRestrictionsDescription": "특정 TCP/UDP 포트에 대한 접근을 제한하거나 모든 포트를 허용/차단하십시오.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "액세스 제어", "editInternalResourceDialogAccessControlDescription": "연결 시 이 리소스에 대한 액세스 권한을 가지는 역할, 사용자, 그리고 머신 클라이언트를 제어합니다. 관리자는 항상 접근할 수 있습니다.", "editInternalResourceDialogPortRangeValidationError": "모든 포트에 대해서는 \"*\"로, 아니면 쉼표로 구분된 포트 및 범위 목록(예: \"80,443,8000-9000\")을 설정해야 합니다. 포트는 1에서 65535 사이여야 합니다.", "internalResourceAuthDaemonStrategy": "SSH 인증 데몬 위치", "internalResourceAuthDaemonStrategyDescription": "SSH 인증 데몬이 작동하는 위치를 선택하세요: 사이트(Newt)에서 또는 원격 호스트에서.", "internalResourceAuthDaemonDescription": "SSH 인증 데몬은 이 리소스를 위한 SSH 키 서명과 PAM 인증을 처리합니다. 사이트(Newt)에서 나 별도의 원격 호스트에서 실행할 것인지를 선택하세요. 자세한 내용은 문서를 참조하세요.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "전략 선택", "internalResourceAuthDaemonStrategyLabel": "위치", "internalResourceAuthDaemonSite": "사이트에서 인증 데몬이 실행됩니다(Newt).", "internalResourceAuthDaemonSiteDescription": "인증 데몬이 사이트(Newt)에서 실행됩니다.", "internalResourceAuthDaemonRemote": "원격 호스트", "internalResourceAuthDaemonRemoteDescription": "인증 데몬이 사이트가 아닌 다른 호스트에서 실행됩니다.", "internalResourceAuthDaemonPort": "데몬 포트 (선택 사항)", "orgAuthWhatsThis": "조직 ID를 어디에서 찾을 수 있습니까?", "learnMore": "자세히 알아보기", "backToHome": "홈으로 돌아가기", "needToSignInToOrg": "조직의 아이덴티티 공급자를 사용해야 합니까?", "maintenanceMode": "유지보수 모드", "maintenanceModeDescription": "방문자에게 유지보수 페이지 표시", "maintenanceModeType": "유지보수 모드 유형", "showMaintenancePage": "방문자에게 유지보수 페이지 표시", "enableMaintenanceMode": "유지보수 모드 활성화", "automatic": "자동", "automaticModeDescription": "백엔드 타깃이 모두 다운되거나 건강하지 않을 때만 유지보수 페이지를 표시합니다. 적어도 하나의 타깃이 건강한 한 리소스는 정상 작동합니다.", "forced": "강제", "forcedModeDescription": "백엔드 상태와 무관하게 항상 유지보수 페이지를 표시하십시오. 모든 접근을 차단하려는 계획된 유지보수 시 사용하세요.", "warning:": "경고:", "forcedeModeWarning": "모든 트래픽이 유지보수 페이지로 전달됩니다. 백엔드 리소스는 어떠한 요청도 받지 않습니다.", "pageTitle": "페이지 제목", "pageTitleDescription": "유지보수 페이지에 표시될 주요 제목", "maintenancePageMessage": "유지보수 메시지", "maintenancePageMessagePlaceholder": "곧 돌아오겠습니다! 사이트는 현재 예정된 유지보수를 진행 중입니다.", "maintenancePageMessageDescription": "유지보수를 설명하는 상세 메시지", "maintenancePageTimeTitle": "예상 완료 시간(선택 사항)", "maintenanceTime": "예: 2시간, 11월 1일 오후 5시", "maintenanceEstimatedTimeDescription": "유지보수가 완료될 것으로 예상되는 시간", "editDomain": "도메인 수정", "editDomainDescription": "리소스를 위한 도메인을 선택하십시오.", "maintenanceModeDisabledTooltip": "이 기능을 사용하려면 유효한 라이선스가 필요합니다.", "maintenanceScreenTitle": "서비스 일시 중단", "maintenanceScreenMessage": "현재 기술적 문제를 겪고 있습니다. 곧 다시 확인하십시오.", "maintenanceScreenEstimatedCompletion": "예상 완료:", "createInternalResourceDialogDestinationRequired": "목적지가 필요합니다.", "available": "사용 가능", "archived": "보관된", "noArchivedDevices": "보관된 장치가 없습니다.", "deviceArchived": "장치가 보관되었습니다.", "deviceArchivedDescription": "장치가 성공적으로 보관되었습니다.", "errorArchivingDevice": "장치를 보관하는 동안 오류가 발생했습니다.", "failedToArchiveDevice": "장치를 보관하는 데 실패했습니다.", "deviceQuestionArchive": "이 장치를 보관하시겠습니까?", "deviceMessageArchive": "장치가 보관되며 당신의 활성 장치 목록에서 제거됩니다.", "deviceArchiveConfirm": "장치 보관", "archiveDevice": "장치 보관", "archive": "보관", "deviceUnarchived": "장치의 보관이 취소되었습니다.", "deviceUnarchivedDescription": "장치의 보관이 성공적으로 취소되었습니다.", "errorUnarchivingDevice": "장치 보관 해제 중 오류가 발생했습니다.", "failedToUnarchiveDevice": "장치 보관 해제 실패", "unarchive": "보관 해제", "archiveClient": "클라이언트 보관", "archiveClientQuestion": "이 클라이언트를 보관하시겠습니까?", "archiveClientMessage": "클라이언트가 보관되며 당신의 활성 클라이언트 목록에서 제거됩니다.", "archiveClientConfirm": "클라이언트 보관 확인", "blockClient": "클라이언트 차단", "blockClientQuestion": "이 클라이언트를 차단하시겠습니까?", "blockClientMessage": "장치가 현재 연결되어 있는 경우 강제로 연결이 해제됩니다. 이후에도 차단 해제가 가능합니다.", "blockClientConfirm": "클라이언트 차단 확인", "active": "활성", "usernameOrEmail": "사용자 이름 또는 이메일", "selectYourOrganization": "조직 선택", "signInTo": "로그인 중", "signInWithPassword": "비밀번호로 계속", "noAuthMethodsAvailable": "이 조직에는 사용할 수 있는 인증 방법이 없습니다.", "enterPassword": "비밀번호를 입력하세요.", "enterMfaCode": "인증 앱에서 제공한 코드를 입력하세요.", "securityKeyRequired": "보안 키를 사용해 로그인하세요.", "needToUseAnotherAccount": "다른 계정을 사용해야 합니까?", "loginLegalDisclaimer": "아래 버튼을 클릭하여 서비스 약관개인 정보 보호 정책을 읽고 이해했으며 동의함을 인정합니다.", "termsOfService": "서비스 약관", "privacyPolicy": "개인 정보 보호 정책", "userNotFoundWithUsername": "해당 사용자 이름으로 사용자를 찾지 못했습니다.", "verify": "확인", "signIn": "로그인", "forgotPassword": "비밀번호를 잊으셨나요?", "orgSignInTip": "이전에 로그인한 적이 있다면, 위의 사용자 이름 또는 이메일을 입력하여 조직의 ID 공급자로 인증할 수 있습니다. 더 쉬워요!", "continueAnyway": "계속하기", "dontShowAgain": "다시 보기 않습니다.", "orgSignInNotice": "아셨나요?", "signupOrgNotice": "로그인 중이신가요?", "signupOrgTip": "조직의 ID 공급자를 통해 로그인하려고 하십니까?", "signupOrgLink": "대신 조직을 사용하여 로그인 또는 가입", "verifyEmailLogInWithDifferentAccount": "다른 계정 사용", "logIn": "로그인", "deviceInformation": "장치 정보", "deviceInformationDescription": "장치와 에이전트 정보", "deviceSecurity": "디바이스 보안", "deviceSecurityDescription": "디바이스 보안 상태 정보", "platform": "플랫폼", "macosVersion": "macOS 버전", "windowsVersion": "Windows 버전", "iosVersion": "iOS 버전", "androidVersion": "Android 버전", "osVersion": "OS 버전", "kernelVersion": "커널 버전", "deviceModel": "장치 모델", "serialNumber": "일련 번호", "hostname": "호스트 이름", "firstSeen": "처음 발견됨", "lastSeen": "마지막으로 발견됨", "biometricsEnabled": "생체 인식 활성화", "diskEncrypted": "디스크 암호화됨", "firewallEnabled": "방화벽 활성화", "autoUpdatesEnabled": "자동 업데이트 활성화", "tpmAvailable": "TPM 사용 가능", "windowsAntivirusEnabled": "안티바이러스 활성화됨", "macosSipEnabled": "시스템 무결성 보호 (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "방화벽 스텔스 모드", "linuxAppArmorEnabled": "AppArmor", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "장치 정보 및 설정 보기", "devicePendingApprovalDescription": "이 장치는 승인을 기다리고 있습니다.", "deviceBlockedDescription": "이 장치는 현재 차단되었습니다. 차단이 해제되지 않으면 리소스에 연결할 수 없습니다.", "unblockClient": "클라이언트 차단 해제", "unblockClientDescription": "장치가 차단 해제되었습니다.", "unarchiveClient": "클라이언트 보관 취소", "unarchiveClientDescription": "장치가 보관 해제되었습니다.", "block": "차단", "unblock": "차단 해제", "deviceActions": "장치 작업", "deviceActionsDescription": "장치 상태 및 접근 관리", "devicePendingApprovalBannerDescription": "이 장치는 승인 대기 중입니다. 승인될 때까지 리소스에 연결할 수 없습니다.", "connected": "연결됨", "disconnected": "연결 해제됨", "approvalsEmptyStateTitle": "장치 승인 비활성화됨", "approvalsEmptyStateDescription": "사용자가 새 장치를 연결하기 전에 관리자의 승인을 필요로 하도록 역할에 대해 장치 승인을 활성화하세요.", "approvalsEmptyStateStep1Title": "역할로 이동", "approvalsEmptyStateStep1Description": "조직의 역할 설정으로 이동하여 장치 승인을 구성하십시오.", "approvalsEmptyStateStep2Title": "장치 승인 활성화", "approvalsEmptyStateStep2Description": "역할을 편집하고 '장치 승인 요구' 옵션을 활성화하세요. 이 역할을 가진 사용자는 새 장치에 대해 관리자의 승인이 필요합니다.", "approvalsEmptyStatePreviewDescription": "미리 보기: 활성화된 경우, 승인 대기 중인 장치 요청이 검토용으로 여기에 표시됩니다.", "approvalsEmptyStateButtonText": "역할 관리" } ================================================ FILE: messages/nb-NO.json ================================================ { "setupCreate": "Opprett organisasjonen, nettstedet og ressursene", "headerAuthCompatibilityInfo": "Aktiver dette for å tvinge frem en 401 Uautorisert-respons når en autentiseringstoken mangler. Dette kreves for nettlesere eller spesifikke HTTP-biblioteker som ikke sender legitimasjon uten en serverutfordring.", "headerAuthCompatibility": "Utvidet kompatibilitet", "setupNewOrg": "Ny Organisasjon", "setupCreateOrg": "Opprett organisasjon", "setupCreateResources": "Opprett ressurser", "setupOrgName": "Organisasjonsnavn", "orgDisplayName": "Dette er organisasjonens visningsnavn.", "orgId": "Organisasjons-ID", "setupIdentifierMessage": "Dette er den unike identifikatoren for organisasjonen.", "setupErrorIdentifier": "Organisasjons-ID er allerede tatt. Vennligst velg en annen.", "componentsErrorNoMemberCreate": "Du er for øyeblikket ikke medlem av noen organisasjoner. Lag en organisasjon for å komme i gang.", "componentsErrorNoMember": "Du er for øyeblikket ikke medlem av noen organisasjoner.", "welcome": "Velkommen!", "welcomeTo": "Velkommen til", "componentsCreateOrg": "Lag en Organisasjon", "componentsMember": "Du er {count, plural, =0 {ikke medlem av noen organisasjoner} one {medlem av en organisasjon} other {medlem av # organisasjoner}}.", "componentsInvalidKey": "Ugyldig eller utgått lisensnøkkel oppdaget. Følg lisensvilkårene for å fortsette å kunne bruke alle funksjonene.", "dismiss": "Avvis", "subscriptionViolationMessage": "Du er utenfor grensen for gjeldende plan. Rett problemet ved å fjerne nettsteder, brukere eller andre ressurser for å bli innenfor planen din.", "subscriptionViolationViewBilling": "Vis fakturering", "componentsLicenseViolation": "Lisens Brudd: Denne serveren bruker {usedSites} områder som overskrider den lisensierte grenser av {maxSites} områder. Følg lisensvilkårene for å fortsette å kunne bruke alle funksjonene.", "componentsSupporterMessage": "Takk for at du støtter Pangolin som en {tier}!", "inviteErrorNotValid": "Beklager, men det ser ut som invitasjonen du prøver å bruke ikke har blitt akseptert eller ikke er gyldig lenger.", "inviteErrorUser": "Vi beklager, men det ser ut som invitasjonen du prøver å få tilgang til, ikke er for denne brukeren.", "inviteLoginUser": "Vennligst sjekk at du er logget inn som riktig bruker.", "inviteErrorNoUser": "Vi beklager, men det ser ut som invitasjonen du prøver å få tilgang til ikke er for en bruker som eksisterer.", "inviteCreateUser": "Vennligst opprett en konto først.", "goHome": "Gå hjem", "inviteLogInOtherUser": "Logg inn som en annen bruker", "createAnAccount": "Lag konto", "inviteNotAccepted": "Invitasjonen ikke akseptert", "authCreateAccount": "Opprett en konto for å komme i gang", "authNoAccount": "Har du ikke konto?", "email": "E-post", "password": "Passord", "confirmPassword": "Bekreft Passord", "createAccount": "Opprett Konto", "viewSettings": "Vis innstillinger", "delete": "Slett", "name": "Navn", "online": "Online", "offline": "Frakoblet", "site": "Område", "dataIn": "Data Inn", "dataOut": "Data Ut", "connectionType": "Tilkoblingstype", "tunnelType": "Tunneltype", "local": "Lokal", "edit": "Rediger", "siteConfirmDelete": "Bekreft Sletting av Område", "siteDelete": "Slett Område", "siteMessageRemove": "Når nettstedet er fjernet, vil det ikke lenger være tilgjengelig. Alle målene for nettstedet vil også bli fjernet.", "siteQuestionRemove": "Er du sikker på at du vil fjerne nettstedet fra organisasjonen?", "siteManageSites": "Administrer Områder", "siteDescription": "Opprette og administrere nettsteder for å aktivere tilkobling til private nettverk", "sitesBannerTitle": "Koble til alle nettverk", "sitesBannerDescription": "Et nettverk er en tilkobling til et eksternt nettverk som tillater Pangolin å gi tilgang til ressurser, enten offentlige eller private, til brukere hvor som helst. Installer nettverkskontaktet (Newt) hvor som helst du kan kjøre en binærfil eller container for å opprette forbindelsen.", "sitesBannerButtonText": "Installer nettsted", "approvalsBannerTitle": "Godkjenn eller avslå tilgang til enhet", "approvalsBannerDescription": "Gjennomgå og godkjenne eller avslå forespørsler om tilgang fra brukere. Når enhetsgodkjenninger er nødvendig, må brukere få admingodkjenning før enhetene kan koble seg til organisasjonens ressurser.", "approvalsBannerButtonText": "Lær mer", "siteCreate": "Opprett område", "siteCreateDescription2": "Følg trinnene nedenfor for å opprette og koble til et nytt område", "siteCreateDescription": "Opprett et nytt nettsted for å koble til ressurser", "close": "Lukk", "siteErrorCreate": "Feil ved oppretting av område", "siteErrorCreateKeyPair": "Nøkkelpar eller standardinnstillinger for område ikke funnet", "siteErrorCreateDefaults": "Standardinnstillinger for område ikke funnet", "method": "Metode", "siteMethodDescription": "Slik eksponerer du tilkoblinger.", "siteLearnNewt": "Lær hvordan du installerer Newt på systemet ditt", "siteSeeConfigOnce": "Du kan kun se konfigurasjonen én gang.", "siteLoadWGConfig": "Laster WireGuard-konfigurasjon...", "siteDocker": "Utvid for detaljer om Docker-deployment", "toggle": "Veksle", "dockerCompose": "Docker Compose", "dockerRun": "Docker Run", "siteLearnLocal": "Lokale områder tunnelerer ikke, lær mer", "siteConfirmCopy": "Jeg har kopiert konfigurasjonen", "searchSitesProgress": "Søker i områder...", "siteAdd": "Legg til område", "siteInstallNewt": "Installer Newt", "siteInstallNewtDescription": "Få Newt til å kjøre på systemet ditt", "WgConfiguration": "WireGuard Konfigurasjon", "WgConfigurationDescription": "Bruk følgende konfigurasjon for å koble til nettverket", "operatingSystem": "Operativsystem", "commands": "Kommandoer", "recommended": "Anbefalt", "siteNewtDescription": "For den beste brukeropplevelsen, bruk Newt. Den bruker WireGuard i bakgrunnen og lar deg adressere dine private ressurser med deres LAN-adresse på ditt private nettverk fra Pangolin-dashbordet.", "siteRunsInDocker": "Kjører i Docker", "siteRunsInShell": "Kjører i skall på macOS, Linux og Windows", "siteErrorDelete": "Feil ved sletting av området", "siteErrorUpdate": "Klarte ikke å oppdatere området", "siteErrorUpdateDescription": "En feil oppstod under oppdatering av området.", "siteUpdated": "Område oppdatert", "siteUpdatedDescription": "Området har blitt oppdatert.", "siteGeneralDescription": "Konfigurer de generelle innstillingene for dette området", "siteSettingDescription": "Konfigurere innstillingene på nettstedet", "siteSetting": "{siteName} Innstillinger", "siteNewtTunnel": "Nyhetsnettsted (anbefalt)", "siteNewtTunnelDescription": "Lekkeste måte å lage et inngangspunkt til ethvert nettverk. Ingen ekstra oppsett på.", "siteWg": "Grunnleggende WireGuard", "siteWgDescription": "Bruk hvilken som helst WireGuard-klient for å etablere en tunnel. Manuell NAT-oppsett kreves.", "siteWgDescriptionSaas": "Bruk hvilken som helst WireGuard-klient for å etablere en tunnel. Manuell NAT-oppsett er nødvendig. FUNGERER KUN PÅ SELVHOSTEDE NODER", "siteLocalDescription": "Kun lokale ressurser. Ingen tunnelering.", "siteLocalDescriptionSaas": "Lokale ressurser. Ingen tunnelering. Bare tilgjengelig på eksterne noder.", "siteSeeAll": "Se alle områder", "siteTunnelDescription": "Avgjør hvordan du vil koble deg til nettstedet", "siteNewtCredentials": "Legitimasjon", "siteNewtCredentialsDescription": "Dette er hvordan nettstedet vil godkjenne med serveren", "remoteNodeCredentialsDescription": "Slik vil den eksterne noden autentisere seg med serveren", "siteCredentialsSave": "Lagre brukeropplysninger", "siteCredentialsSaveDescription": "Du vil kun kunne se dette én gang. Sørg for å kopiere det til et sikkert sted.", "siteInfo": "Områdeinformasjon", "status": "Status", "shareTitle": "Administrer delingslenker", "shareDescription": "Opprett delbare lenker for å gi midlertidige eller permanent tilgang til proxyressurser", "shareSearch": "Søk delingslenker...", "shareCreate": "Opprett delingslenke", "shareErrorDelete": "Klarte ikke å slette lenke", "shareErrorDeleteMessage": "En feil oppstod ved sletting av lenke", "shareDeleted": "Lenke slettet", "shareDeletedDescription": "Lenken har blitt slettet", "shareTokenDescription": "Adgangstoken kan sendes på to måter: som en spørringsparameter eller i forespørselsoverskriftene. Disse må sendes fra klienten på hver forespørsel om autentisert tilgang.", "accessToken": "Tilgangsnøkkel", "usageExamples": "Brukseksempler", "tokenId": "Token-ID", "requestHeades": "Request Headers", "queryParameter": "Forespørsel Params", "importantNote": "Viktig merknad", "shareImportantDescription": "Av sikkerhetsgrunner anbefales det å bruke headere fremfor query parametere der det er mulig, da query parametere kan logges i serverlogger eller nettleserhistorikk.", "token": "Token", "shareTokenSecurety": "Hold tilgangstoken sikker. Ikke del den i offentlig tilgjengelige områder eller klientsidekode.", "shareErrorFetchResource": "Klarte ikke å hente ressurser", "shareErrorFetchResourceDescription": "En feil oppstod under henting av ressursene", "shareErrorCreate": "Mislyktes med å opprette delingslenke", "shareErrorCreateDescription": "Det oppsto en feil ved opprettelse av delingslenken", "shareCreateDescription": "Alle med denne lenken får tilgang til ressursen", "shareTitleOptional": "Tittel (valgfritt)", "expireIn": "Utløper om", "neverExpire": "Utløper aldri", "shareExpireDescription": "Utløpstid er hvor lenge lenken vil være brukbar og gi tilgang til ressursen. Etter denne tiden vil lenken ikke lenger fungere, og brukere som brukte denne lenken vil miste tilgangen til ressursen.", "shareSeeOnce": "Du vil bare kunne se denne linken én gang. Pass på å kopiere den.", "shareAccessHint": "Alle med denne lenken kan få tilgang til ressursen. Del forsiktig.", "shareTokenUsage": "Se tilgangstokenbruk", "createLink": "Opprett lenke", "resourcesNotFound": "Ingen ressurser funnet", "resourceSearch": "Søk i ressurser", "openMenu": "Åpne meny", "resource": "Ressurs", "title": "Tittel", "created": "Opprettet", "expires": "Utløper", "never": "Aldri", "shareErrorSelectResource": "Vennligst velg en ressurs", "proxyResourceTitle": "Administrere offentlige ressurser", "proxyResourceDescription": "Opprett og administrer ressurser som er offentlig tilgjengelige via en nettleser", "proxyResourcesBannerTitle": "Nettbasert offentlig tilgang", "proxyResourcesBannerDescription": "Offentlige ressurser er HTTPS- eller TCP/UDP-proxyer tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klient-basert programvare og kan inkludere identitets- og kontekstbevisste tilgangspolicyer.", "clientResourceTitle": "Administrer private ressurser", "clientResourceDescription": "Opprette og administrere ressurser som bare er tilgjengelige via en tilkoblet klient", "privateResourcesBannerTitle": "Zero-Trust privat tilgang", "privateResourcesBannerDescription": "Private ressurser bruker Zero-Trust-sikkerhet, og sikrer at brukere og maskiner kun kan få tilgang til ressurser du eksplisitt gir tillatelse til. Koble bruker-enheter eller maskinklienter for å få tilgang til disse ressursene via et sikkert virtuelt privat nettverk.", "resourcesSearch": "Søk i ressurser...", "resourceAdd": "Legg til ressurs", "resourceErrorDelte": "Feil ved sletting av ressurs", "authentication": "Autentisering", "protected": "Beskyttet", "notProtected": "Ikke beskyttet", "resourceMessageRemove": "Når den er fjernet, vil ressursen ikke lenger være tilgjengelig. Alle mål knyttet til ressursen vil også bli fjernet.", "resourceQuestionRemove": "Er du sikker på at du vil fjerne ressursen fra organisasjonen?", "resourceHTTP": "HTTPS-ressurs", "resourceHTTPDescription": "Proxy forespørsler over HTTPS ved å bruke et fullstendig kvalifisert domenenavn.", "resourceRaw": "Rå TCP/UDP-ressurs", "resourceRawDescription": "Proxy forespørsler over rå TCP/UDP ved å bruke et portnummer.", "resourceRawDescriptionCloud": "Proxy ber om et portnummer. Om du vil bruke et sportsnummer.", "resourceCreate": "Opprett ressurs", "resourceCreateDescription": "Følg trinnene nedenfor for å opprette en ny ressurs", "resourceSeeAll": "Se alle ressurser", "resourceInfo": "Ressursinformasjon", "resourceNameDescription": "Dette er visningsnavnet for ressursen.", "siteSelect": "Velg område", "siteSearch": "Søk i område", "siteNotFound": "Ingen område funnet.", "selectCountry": "Velg land", "searchCountries": "Søk land...", "noCountryFound": "Ingen land funnet.", "siteSelectionDescription": "Dette området vil gi tilkobling til mål.", "resourceType": "Ressurstype", "resourceTypeDescription": "Bestemme hvordan denne ressursen skal brukes", "resourceHTTPSSettings": "HTTPS-innstillinger", "resourceHTTPSSettingsDescription": "Konfigurer hvordan ressursen skal nås over HTTPS", "domainType": "Domenetype", "subdomain": "Underdomene", "baseDomain": "Grunndomene", "subdomnainDescription": "Underdomenet hvor ressursen vil være tilgjengelig.", "resourceRawSettings": "TCP/UDP-innstillinger", "resourceRawSettingsDescription": "Konfigurer hvordan ressursen vil bli tilgjengelig over TCP/UDP", "protocol": "Protokoll", "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", "resourceAddEntrypoints": "Traefik: Legg til inngangspunkter", "resourceExposePorts": "Gerbil: Eksponer Porter i Docker Compose", "resourceLearnRaw": "Lær hvordan å konfigurere TCP/UDP-ressurser", "resourceBack": "Tilbake til ressurser", "resourceGoTo": "Gå til ressurs", "resourceDelete": "Slett ressurs", "resourceDeleteConfirm": "Bekreft sletting av ressurs", "visibility": "Synlighet", "enabled": "Aktivert", "disabled": "Deaktivert", "general": "Generelt", "generalSettings": "Generelle innstillinger", "proxy": "Proxy", "internal": "Intern", "rules": "Regler", "resourceSettingDescription": "Konfigurere innstillingene på ressursen", "resourceSetting": "{resourceName} Innstillinger", "alwaysAllow": "Omgå Auth", "alwaysDeny": "Blokker tilgang", "passToAuth": "Pass til Autentisering", "orgSettingsDescription": "Konfigurere organisasjonens innstillinger", "orgGeneralSettings": "Organisasjonsinnstillinger", "orgGeneralSettingsDescription": "Behandle organisasjonens detaljer og konfigurasjon", "saveGeneralSettings": "Lagre generelle innstillinger", "saveSettings": "Lagre innstillinger", "orgDangerZone": "Faresone", "orgDangerZoneDescription": "Når du sletter denne organisasjonen er det ingen vei tilbake. Vennligst vær sikker.", "orgDelete": "Slett organisasjon", "orgDeleteConfirm": "Bekreft Sletting av Organisasjon", "orgMessageRemove": "Denne handlingen er irreversibel og vil slette alle tilknyttede data.", "orgMessageConfirm": "For å bekrefte, vennligst skriv inn navnet på organisasjonen nedenfor.", "orgQuestionRemove": "Er du sikker på at du vil fjerne organisasjonen?", "orgUpdated": "Organisasjon oppdatert", "orgUpdatedDescription": "Organisasjonen har blitt oppdatert.", "orgErrorUpdate": "Kunne ikke oppdatere organisasjonen", "orgErrorUpdateMessage": "En feil oppsto under oppdatering av organisasjonen.", "orgErrorFetch": "Klarte ikke å hente organisasjoner", "orgErrorFetchMessage": "Det oppstod en feil under opplisting av organisasjonene dine", "orgErrorDelete": "Klarte ikke å slette organisasjon", "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", "accessUsersDescription": "Inviter og behandle brukere med tilgang til denne organisasjonen", "accessUsersSearch": "Søk etter brukere...", "accessUserCreate": "Opprett bruker", "accessUserRemove": "Fjern bruker", "username": "Brukernavn", "identityProvider": "Identitetsleverandør", "role": "Rolle", "nameRequired": "Navn er påkrevd", "accessRolesManage": "Administrer Roller", "accessRolesDescription": "Opprett og administrer roller for brukere i organisasjonen", "accessRolesSearch": "Søk etter roller...", "accessRolesAdd": "Legg til rolle", "accessRoleDelete": "Slett rolle", "accessApprovalsManage": "Behandle godkjenninger", "accessApprovalsDescription": "Se og administrer ventende godkjenninger for tilgang til denne organisasjonen", "description": "Beskrivelse", "inviteTitle": "Åpne invitasjoner", "inviteDescription": "Administrer invitasjoner til andre brukere for å bli med i organisasjonen", "inviteSearch": "Søk i invitasjoner...", "minutes": "Minutter", "hours": "Timer", "days": "Dager", "weeks": "Uker", "months": "Måneder", "years": "År", "day": "{count, plural, one {en dag} other {# dager}}", "apiKeysTitle": "API-nøkkel informasjon", "apiKeysConfirmCopy2": "Du må bekrefte at du har kopiert API-nøkkelen.", "apiKeysErrorCreate": "Feil ved oppretting av API-nøkkel", "apiKeysErrorSetPermission": "Feil ved innstilling av tillatelser", "apiKeysCreate": "Generer API-nøkkel", "apiKeysCreateDescription": "Generer en ny API-nøkkel for organisasjonen", "apiKeysGeneralSettings": "Tillatelser", "apiKeysGeneralSettingsDescription": "Finn ut hva denne API-nøkkelen kan gjøre", "apiKeysList": "Ny API-nøkkel", "apiKeysSave": "Lagre API-nøkkel", "apiKeysSaveDescription": "Du vil bare kunne se dette én gang. Sørg for å kopiere det til et sikkert sted.", "apiKeysInfo": "API-nøkkelen er:", "apiKeysConfirmCopy": "Jeg har kopiert API-nøkkelen", "generate": "Generer", "done": "Ferdig", "apiKeysSeeAll": "Se alle API-nøkler", "apiKeysPermissionsErrorLoadingActions": "Feil ved innlasting av API-nøkkel handlinger", "apiKeysPermissionsErrorUpdate": "Feil ved innstilling av tillatelser", "apiKeysPermissionsUpdated": "Tillatelser oppdatert", "apiKeysPermissionsUpdatedDescription": "Tillatelsene har blitt oppdatert.", "apiKeysPermissionsGeneralSettings": "Tillatelser", "apiKeysPermissionsGeneralSettingsDescription": "Bestem hva denne API-nøkkelen kan gjøre", "apiKeysPermissionsSave": "Lagre tillatelser", "apiKeysPermissionsTitle": "Tillatelser", "apiKeys": "API-nøkler", "searchApiKeys": "Søk API-nøkler", "apiKeysAdd": "Generer API-nøkkel", "apiKeysErrorDelete": "Feil under sletting av API-nøkkel", "apiKeysErrorDeleteMessage": "Feil ved sletting av API-nøkkel", "apiKeysQuestionRemove": "Er du sikker på at du vil fjerne API-nøkkelen fra organisasjonen?", "apiKeysMessageRemove": "Når den er fjernet, vil API-nøkkelen ikke lenger kunne brukes.", "apiKeysDeleteConfirm": "Bekreft sletting av API-nøkkel", "apiKeysDelete": "Slett API-nøkkel", "apiKeysManage": "Administrer API-nøkler", "apiKeysDescription": "API-nøkler brukes for å autentisere med integrasjons-API", "apiKeysSettings": "{apiKeyName} Innstillinger", "userTitle": "Administrer alle brukere", "userDescription": "Vis og administrer alle brukere i systemet", "userAbount": "Om brukeradministrasjon", "userAbountDescription": "Denne tabellen viser alle rotbrukerobjekter i systemet. Hver bruker kan tilhøre flere organisasjoner. Å fjerne en bruker fra en organisasjon sletter ikke deres rotbrukerobjekt – de vil forbli i systemet. For å fullstendig fjerne en bruker fra systemet, må du slette deres rotbrukerobjekt ved å bruke slett-handlingen i denne tabellen.", "userServer": "Serverbrukere", "userSearch": "Søk serverbrukere...", "userErrorDelete": "Feil ved sletting av bruker", "userDeleteConfirm": "Bekreft sletting av bruker", "userDeleteServer": "Slett bruker fra server", "userMessageRemove": "Brukeren vil bli fjernet fra alle organisasjoner og vil bli fullstendig fjernet fra serveren.", "userQuestionRemove": "Er du sikker på at du vil slette brukeren permanent fra serveren?", "licenseKey": "Lisensnøkkel", "valid": "Gyldig", "numberOfSites": "Antall områder", "licenseKeySearch": "Søk lisensnøkler...", "licenseKeyAdd": "Legg til lisensnøkkel", "type": "Type", "licenseKeyRequired": "Lisensnøkkel er påkrevd", "licenseTermsAgree": "Du må godta lisensvilkårene", "licenseErrorKeyLoad": "Feil ved lasting av lisensnøkler", "licenseErrorKeyLoadDescription": "Det oppstod en feil ved lasting av lisensnøkler.", "licenseErrorKeyDelete": "Kunne ikke slette lisensnøkkel", "licenseErrorKeyDeleteDescription": "Det oppstod en feil ved sletting av lisensnøkkel.", "licenseKeyDeleted": "Lisensnøkkel slettet", "licenseKeyDeletedDescription": "Lisensnøkkelen har blitt slettet.", "licenseErrorKeyActivate": "Aktivering av lisensnøkkel feilet", "licenseErrorKeyActivateDescription": "Det oppstod en feil under aktivering av lisensnøkkelen.", "licenseAbout": "Om Lisensiering", "communityEdition": "Fellesskapsutgave", "licenseAboutDescription": "Dette er for bedrifts- og foretaksbrukere som bruker Pangolin i et kommersielt miljø. Hvis du bruker Pangolin til personlig bruk, kan du ignorere denne seksjonen.", "licenseKeyActivated": "Lisensnøkkel aktivert", "licenseKeyActivatedDescription": "Lisensnøkkelen har blitt vellykket aktivert.", "licenseErrorKeyRecheck": "En feil oppsto under verifisering av lisensnøkler", "licenseErrorKeyRecheckDescription": "Det oppstod en feil under verifisering av lisensnøkler.", "licenseErrorKeyRechecked": "Lisensnøkler verifisert", "licenseErrorKeyRecheckedDescription": "Alle lisensnøkler er verifisert", "licenseActivateKey": "Aktiver lisensnøkkel", "licenseActivateKeyDescription": "Skriv inn en lisensnøkkel for å aktivere den.", "licenseActivate": "Aktiver lisens", "licenseAgreement": "Ved å krysse av denne boksen bekrefter du at du har lest og godtar lisensvilkårene som tilsvarer nivået tilknyttet lisensnøkkelen din.", "fossorialLicense": "Vis Fossorial kommersiell lisens og abonnementsvilkår", "licenseMessageRemove": "Dette vil fjerne lisensnøkkelen og alle tilknyttede tillatelser gitt av den.", "licenseMessageConfirm": "For å bekrefte, vennligst skriv inn lisensnøkkelen nedenfor.", "licenseQuestionRemove": "Er du sikker på at du vil slette lisensnøkkelen?", "licenseKeyDelete": "Slett Lisensnøkkel", "licenseKeyDeleteConfirm": "Bekreft sletting av lisensnøkkel", "licenseTitle": "Behandle lisensstatus", "licenseTitleDescription": "Se og administrer lisensnøkler i systemet", "licenseHost": "Vertslisens", "licenseHostDescription": "Behandle hovedlisensnøkkelen for verten.", "licensedNot": "Ikke lisensiert", "hostId": "Verts-ID", "licenseReckeckAll": "Verifiser alle nøkler", "licenseSiteUsage": "Område Bruk", "licenseSiteUsageDecsription": "Vis antall områder som bruker denne lisensen.", "licenseNoSiteLimit": "Det er ingen grense på antall områder som bruker en ulisensiert vert.", "licensePurchase": "Kjøp lisens", "licensePurchaseSites": "Kjøp flere områder", "licenseSitesUsedMax": "{usedSites} av {maxSites} områder brukt", "licenseSitesUsed": "{count, plural, =0 {ingen områder} one {ett område} other {# områder}} i systemet.", "licensePurchaseDescription": "Velg hvor mange områder du vil {selectedMode, select, license {kjøpe en lisens for. Du kan alltid legge til flere områder senere.} other {legge til din eksisterende lisens.}}", "licenseFee": "Lisensavgift", "licensePriceSite": "Pris per område", "total": "Totalt", "licenseContinuePayment": "Fortsett til betaling", "pricingPage": "Pris oversikt", "pricingPortal": "Se Kjøpsportal", "licensePricingPage": "For de mest oppdaterte prisene og rabattene, vennligst besøk", "invite": "Invitasjoner", "inviteRegenerate": "Regenerer invitasjonen", "inviteRegenerateDescription": "Tilbakekall tidligere invitasjon og opprette en ny", "inviteRemove": "Fjern invitasjon", "inviteRemoveError": "Mislyktes å fjerne invitasjon", "inviteRemoveErrorDescription": "Det oppstod en feil under fjerning av invitasjonen.", "inviteRemoved": "Invitasjon fjernet", "inviteRemovedDescription": "Invitasjonen for {email} er fjernet.", "inviteQuestionRemove": "Er du sikker på at du vil fjerne invitasjonen?", "inviteMessageRemove": "Når fjernet, vil denne invitasjonen ikke lenger være gyldig. Du kan alltid invitere brukeren på nytt senere.", "inviteMessageConfirm": "For å bekrefte, vennligst tast inn invitasjonens e-postadresse nedenfor.", "inviteQuestionRegenerate": "Er du sikker på at du vil generere invitasjonen på nytt for {email}? Dette vil ugyldiggjøre den forrige invitasjonen.", "inviteRemoveConfirm": "Bekreft fjerning av invitasjon", "inviteRegenerated": "Invitasjon fornyet", "inviteSent": "En ny invitasjon er sendt til {email}.", "inviteSentEmail": "Send e-postvarsel til brukeren", "inviteGenerate": "En ny invitasjon er generert for {email}.", "inviteDuplicateError": "Dupliser invitasjon", "inviteDuplicateErrorDescription": "En invitasjon for denne brukeren eksisterer allerede.", "inviteRateLimitError": "Forespørselsgrense overskredet", "inviteRateLimitErrorDescription": "Du har overskredet grensen på 3 regenerasjoner per time. Prøv igjen senere.", "inviteRegenerateError": "Kunne ikke regenerere invitasjon", "inviteRegenerateErrorDescription": "Det oppsto en feil under regenerering av invitasjonen.", "inviteValidityPeriod": "Gyldighetsperiode", "inviteValidityPeriodSelect": "Velg gyldighetsperiode", "inviteRegenerateMessage": "Invitasjonen er generert på nytt. Brukeren må gå til lenken nedenfor for å akseptere invitasjonen.", "inviteRegenerateButton": "Regenerer", "expiresAt": "Utløpstidspunkt", "accessRoleUnknown": "Ukjent rolle", "placeholder": "Plassholder", "userErrorOrgRemove": "En feil oppsto under fjerning av bruker", "userErrorOrgRemoveDescription": "Det oppstod en feil under fjerning av brukeren.", "userOrgRemoved": "Bruker fjernet", "userOrgRemovedDescription": "Brukeren {email} er fjernet fra organisasjonen.", "userQuestionOrgRemove": "Er du sikker på at du vil fjerne denne brukeren fra organisasjonen?", "userMessageOrgRemove": "Når denne brukeren er fjernet, vil de ikke lenger ha tilgang til organisasjonen. Du kan alltid invitere dem på nytt senere, men de vil måtte godta invitasjonen på nytt.", "userRemoveOrgConfirm": "Bekreft fjerning av bruker", "userRemoveOrg": "Fjern bruker fra organisasjon", "users": "Brukere", "accessRoleMember": "Medlem", "accessRoleOwner": "Eier", "userConfirmed": "Bekreftet", "idpNameInternal": "Intern", "emailInvalid": "Ugyldig e-postadresse", "inviteValidityDuration": "Vennligst velg en varighet", "accessRoleSelectPlease": "Vennligst velg en rolle", "usernameRequired": "Brukernavn er påkrevd", "idpSelectPlease": "Vennligst velg en identitetsleverandør", "idpGenericOidc": "Generisk OAuth2/OIDC-leverandør.", "accessRoleErrorFetch": "En feil oppsto under henting av roller", "accessRoleErrorFetchDescription": "En feil oppsto under henting av rollene", "idpErrorFetch": "En feil oppsto under henting av identitetsleverandører", "idpErrorFetchDescription": "En feil oppsto ved henting av identitetsleverandører", "userErrorExists": "Bruker eksisterer allerede", "userErrorExistsDescription": "Denne brukeren er allerede medlem av organisasjonen.", "inviteError": "Kunne ikke invitere bruker", "inviteErrorDescription": "En feil oppsto under invitering av brukeren", "userInvited": "Bruker invitert", "userInvitedDescription": "Brukeren er vellykket invitert.", "userErrorCreate": "Kunne ikke opprette bruker", "userErrorCreateDescription": "Det oppsto en feil under oppretting av brukeren", "userCreated": "Bruker opprettet", "userCreatedDescription": "Brukeren har blitt vellykket opprettet.", "userTypeInternal": "Intern bruker", "userTypeInternalDescription": "Inviter en bruker til å bli med direkte på organisasjonen.", "userTypeExternal": "Ekstern bruker", "userTypeExternalDescription": "Opprett en bruker med en ekstern identitetsleverandør.", "accessUserCreateDescription": "Følg stegene under for å opprette en ny bruker", "userSeeAll": "Se alle brukere", "userTypeTitle": "Brukertype", "userTypeDescription": "Bestem hvordan du vil opprette brukeren", "userSettings": "Brukerinformasjon", "userSettingsDescription": "Skriv inn detaljene for den nye brukeren", "inviteEmailSent": "Send invitasjonsepost til bruker", "inviteValid": "Gyldig for", "selectDuration": "Velg varighet", "selectResource": "Velg ressurs", "filterByResource": "Filtrer etter ressurser", "selectApprovalState": "Velg godkjenningsstatus", "filterByApprovalState": "Filtrer etter godkjenningsstatus", "approvalListEmpty": "Ingen godkjenninger", "approvalState": "Godkjennings tilstand", "approvalLoadMore": "Last mer", "loadingApprovals": "Laster inn godkjenninger", "approve": "Godkjenn", "approved": "Godkjent", "denied": "Avvist", "deniedApproval": "Avslått godkjenning", "all": "Alle", "deny": "Avslå", "viewDetails": "Vis detaljer", "requestingNewDeviceApproval": "forespurt en ny enhet", "resetFilters": "Tilbakestill filtre", "totalBlocked": "Forespørsler blokkert av Pangolin", "totalRequests": "Totalt antall forespørsler", "requestsByCountry": "Forespørsler fra land", "requestsByDay": "Forespørsler per dag", "blocked": "Blokkert", "allowed": "Tillatt", "topCountries": "Flest land", "accessRoleSelect": "Velg rolle", "inviteEmailSentDescription": "En e-post er sendt til brukeren med tilgangslenken nedenfor. De må åpne lenken for å akseptere invitasjonen.", "inviteSentDescription": "Brukeren har blitt invitert. De må åpne lenken nedenfor for å godta invitasjonen.", "inviteExpiresIn": "Invitasjonen utløper om {days, plural, one {en dag} other {# dager}}.", "idpTitle": "Identitetsleverandør", "idpSelect": "Velg identitetsleverandøren for den eksterne brukeren", "idpNotConfigured": "Ingen identitetsleverandører er konfigurert. Vennligst konfigurer en identitetsleverandør før du oppretter eksterne brukere.", "usernameUniq": "Dette må matche det unike brukernavnet som finnes i den valgte identitetsleverandøren.", "emailOptional": "E-post (Valgfritt)", "nameOptional": "Navn (valgfritt)", "accessControls": "Tilgangskontroller", "userDescription2": "Administrer innstillingene for denne brukeren", "accessRoleErrorAdd": "Kunne ikke legge til bruker i rolle", "accessRoleErrorAddDescription": "Det oppstod en feil under tilordning av brukeren til rollen.", "userSaved": "Bruker lagret", "userSavedDescription": "Brukeren har blitt oppdatert.", "autoProvisioned": "Auto avlyst", "autoProvisionedDescription": "Tillat denne brukeren å bli automatisk administrert av en identitetsleverandør", "accessControlsDescription": "Administrer hva denne brukeren kan få tilgang til og gjøre i organisasjonen", "accessControlsSubmit": "Lagre tilgangskontroller", "roles": "Roller", "accessUsersRoles": "Administrer brukere og roller", "accessUsersRolesDescription": "Inviter brukere og legg dem til roller for å administrere tilgang til organisasjonen", "key": "Nøkkel", "createdAt": "Opprettet", "proxyErrorInvalidHeader": "Ugyldig verdi for egendefinert vertsoverskrift. Bruk domenenavnformat, eller lagre tomt for å fjerne den egendefinerte vertsoverskriften.", "proxyErrorTls": "Ugyldig TLS-servernavn. Bruk domenenavnformat, eller la stå tomt for å fjerne TLS-servernavnet.", "proxyEnableSSL": "Aktiver SSL", "proxyEnableSSLDescription": "Aktivere SSL/TLS-kryptering for sikker HTTPS tilkobling til målene.", "target": "Target", "configureTarget": "Konfigurer mål", "targetErrorFetch": "Kunne ikke hente mål", "targetErrorFetchDescription": "Det oppsto en feil under henting av mål", "siteErrorFetch": "Klarte ikke å hente ressurs", "siteErrorFetchDescription": "Det oppstod en feil under henting av ressurs", "targetErrorDuplicate": "Dupliser mål", "targetErrorDuplicateDescription": "Et mål med disse innstillingene finnes allerede", "targetWireGuardErrorInvalidIp": "Ugyldig mål-IP", "targetWireGuardErrorInvalidIpDescription": "Mål-IP må være i områdets undernett.", "targetsUpdated": "Mål oppdatert", "targetsUpdatedDescription": "Mål og innstillinger oppdatert vellykket", "targetsErrorUpdate": "Feilet å oppdatere mål", "targetsErrorUpdateDescription": "En feil oppsto under oppdatering av mål", "targetTlsUpdate": "TLS-innstillinger oppdatert", "targetTlsUpdateDescription": "TLS-innstillinger har blitt oppdatert", "targetErrorTlsUpdate": "Feilet under oppdatering av TLS-innstillinger", "targetErrorTlsUpdateDescription": "Det oppstod en feil under oppdatering av TLS-innstillinger", "proxyUpdated": "Proxy-innstillinger oppdatert", "proxyUpdatedDescription": "Proxy innstillinger har blitt oppdatert", "proxyErrorUpdate": "En feil oppsto under oppdatering av proxyinnstillinger", "proxyErrorUpdateDescription": "En feil oppsto under oppdatering av proxyinnstillinger", "targetAddr": "Vert", "targetPort": "Port", "targetProtocol": "Protokoll", "targetTlsSettings": "Sikker tilkoblings-konfigurasjon", "targetTlsSettingsDescription": "Konfigurer SSL/TLS-innstillinger for ressursen", "targetTlsSettingsAdvanced": "Avanserte TLS-innstillinger", "targetTlsSni": "TLS servernavn", "targetTlsSniDescription": "TLS-servernavnet som skal brukes for SNI. La stå tomt for å bruke standardverdien.", "targetTlsSubmit": "Lagre innstillinger", "targets": "Målkonfigurasjon", "targetsDescription": "Sett opp mål for rutetrafikk til backend tjenestene", "targetStickySessions": "Aktiver klebrige sesjoner", "targetStickySessionsDescription": "Behold tilkoblinger på samme bakend-mål gjennom hele sesjonen.", "methodSelect": "Velg metode", "targetSubmit": "Legg til mål", "targetNoOne": "Denne ressursen har ikke noen mål. Legg til et mål for å konfigurere hvor du vil sende forespørsler til backend.", "targetNoOneDescription": "Å legge til mer enn ett mål ovenfor vil aktivere lastbalansering.", "targetsSubmit": "Lagre mål", "addTarget": "Legg til mål", "targetErrorInvalidIp": "Ugyldig IP-adresse", "targetErrorInvalidIpDescription": "Skriv inn en gyldig IP-adresse eller vertsnavn", "targetErrorInvalidPort": "Ugyldig port", "targetErrorInvalidPortDescription": "Vennligst skriv inn et gyldig portnummer", "targetErrorNoSite": "Ingen nettsted valgt", "targetErrorNoSiteDescription": "Velg et nettsted for målet", "targetCreated": "Mål opprettet", "targetCreatedDescription": "Målet har blitt opprettet", "targetErrorCreate": "Kunne ikke opprette målet", "targetErrorCreateDescription": "Det oppstod en feil under oppretting av målet", "tlsServerName": "TLS servernavn", "tlsServerNameDescription": "Tjenernavnet som skal brukes for SNI", "save": "Lagre", "proxyAdditional": "Ytterligere Proxy-innstillinger", "proxyAdditionalDescription": "Konfigurer hvordan ressursen håndterer proxy-innstillingene", "proxyCustomHeader": "Tilpasset verts-header", "proxyCustomHeaderDescription": "Verts-header som skal settes ved videresending av forespørsler. La stå tom for å bruke standardinnstillingen.", "proxyAdditionalSubmit": "Lagre proxy-innstillinger", "subnetMaskErrorInvalid": "Ugyldig subnettmaske. Må være mellom 0 og 32.", "ipAddressErrorInvalidFormat": "Ugyldig IP-adresseformat", "ipAddressErrorInvalidOctet": "Ugyldig IP-adresse-oktet", "path": "Sti", "matchPath": "Match sti", "ipAddressRange": "IP-område", "rulesErrorFetch": "Klarte ikke å hente regler", "rulesErrorFetchDescription": "Det oppsto en feil under henting av regler", "rulesErrorDuplicate": "Duplisert regel", "rulesErrorDuplicateDescription": "En regel med disse innstillingene finnes allerede", "rulesErrorInvalidIpAddressRange": "Ugyldig CIDR", "rulesErrorInvalidIpAddressRangeDescription": "Vennligst skriv inn en gyldig CIDR-verdi", "rulesErrorInvalidUrl": "Ugyldig URL-sti", "rulesErrorInvalidUrlDescription": "Skriv inn en gyldig verdi for URL-sti", "rulesErrorInvalidIpAddress": "Ugyldig IP", "rulesErrorInvalidIpAddressDescription": "Skriv inn en gyldig IP-adresse", "rulesErrorUpdate": "Kunne ikke oppdatere regler", "rulesErrorUpdateDescription": "Det oppsto en feil under oppdatering av regler", "rulesUpdated": "Aktiver Regler", "rulesUpdatedDescription": "Regelevalueringen har blitt oppdatert", "rulesMatchIpAddressRangeDescription": "Angi en adresse i CIDR-format (f.eks., 103.21.244.0/22)", "rulesMatchIpAddress": "Angi en IP-adresse (f.eks. 103.21.244.12)", "rulesMatchUrl": "Skriv inn en URL-sti eller et mønster (f.eks. /api/v1/todos eller /api/v1/*)", "rulesErrorInvalidPriority": "Ugyldig prioritet", "rulesErrorInvalidPriorityDescription": "Vennligst skriv inn en gyldig prioritet", "rulesErrorDuplicatePriority": "Dupliserte prioriteringer", "rulesErrorDuplicatePriorityDescription": "Vennligst angi unike prioriteringer", "ruleUpdated": "Regler oppdatert", "ruleUpdatedDescription": "Reglene er oppdatert", "ruleErrorUpdate": "Operasjon mislyktes", "ruleErrorUpdateDescription": "En feil oppsto under lagringsoperasjonen", "rulesPriority": "Prioritet", "rulesAction": "Handling", "rulesMatchType": "Trefftype", "value": "Verdi", "rulesAbout": "Om regler", "rulesAboutDescription": "Regler gir mulighet til å kontrollere tilgangen til ressursen basert på et sett av kriterier. Du kan opprette regler for å tillate eller nekte tilgang basert på IP-adresse eller URL-bane.", "rulesActions": "Handlinger", "rulesActionAlwaysAllow": "Alltid Tillat: Omgå alle autentiserings metoder", "rulesActionAlwaysDeny": "Alltid Nekt: Blokker alle forespørsler; ingen autentisering kan forsøkes", "rulesActionPassToAuth": "Pass til Autentisering: Tillat at autentiseringsmetoder forsøkes", "rulesMatchCriteria": "Samsvarende kriterier", "rulesMatchCriteriaIpAddress": "Samsvar med en spesifikk IP-adresse", "rulesMatchCriteriaIpAddressRange": "Samsvar et IP-adresseområde i CIDR-notasjon", "rulesMatchCriteriaUrl": "Match en URL-sti eller et mønster", "rulesEnable": "Aktiver regler", "rulesEnableDescription": "Aktiver eller deaktiver regelvurdering for denne ressursen", "rulesResource": "Konfigurasjon av ressursregler", "rulesResourceDescription": "Konfigurer regler for å kontrollere tilgang til ressursen", "ruleSubmit": "Legg til regel", "rulesNoOne": "Ingen regler. Legg til en regel ved å bruke skjemaet.", "rulesOrder": "Regler evalueres etter prioritet i stigende rekkefølge.", "rulesSubmit": "Lagre regler", "resourceErrorCreate": "Feil under oppretting av ressurs", "resourceErrorCreateDescription": "Det oppstod en feil under oppretting av ressursen", "resourceErrorCreateMessage": "Feil ved oppretting av ressurs:", "resourceErrorCreateMessageDescription": "En uventet feil oppstod", "sitesErrorFetch": "Feil ved henting av områder", "sitesErrorFetchDescription": "En feil oppstod ved henting av områdene", "domainsErrorFetch": "Kunne ikke hente domener", "domainsErrorFetchDescription": "Det oppsto en feil under henting av domenene", "none": "Ingen", "unknown": "Ukjent", "resources": "Ressurser", "resourcesDescription": "Ressurser er proxyer til applikasjoner som kjører i det private nettverket. Opprett en ressurs for enhver HTTP/HTTPS eller rå TCP/UDP tjeneste på ditt private nettverk. Hver ressurs må kobles til et nettsted for å aktivere privat, sikker tilkobling gjennom en kryptert WireGuard tunnel.", "resourcesWireGuardConnect": "Sikker tilkobling med WireGuard-kryptering", "resourcesMultipleAuthenticationMethods": "Konfigurer flere autentiseringsmetoder", "resourcesUsersRolesAccess": "Bruker- og rollebasert tilgangskontroll", "resourcesErrorUpdate": "Feilet å slå av/på ressurs", "resourcesErrorUpdateDescription": "En feil oppstod under oppdatering av ressursen", "access": "Tilgang", "accessControl": "Tilgangskontroll", "shareLink": "{resource} Del Lenke", "resourceSelect": "Velg ressurs", "shareLinks": "Del lenker", "share": "Delbare lenker", "shareDescription2": "Opprett delbare lenker til ressurser. Lenker gir midlertidig eller ubegrenset tilgang til din ressurs. Du kan konfigurere utløpsvarigheten på lenken når du oppretter en.", "shareEasyCreate": "Enkelt å lage og dele", "shareConfigurableExpirationDuration": "Konfigurerbar utløpsvarighet", "shareSecureAndRevocable": "Sikker og tilbakekallbar", "nameMin": "Navn må være minst {len} tegn.", "nameMax": "Navn kan ikke være lengre enn {len} tegn.", "sitesConfirmCopy": "Vennligst bekreft at du har kopiert konfigurasjonen.", "unknownCommand": "Ukjent kommando", "newtErrorFetchReleases": "Feilet å hente utgivelsesinfo: {err}", "newtErrorFetchLatest": "Feil ved henting av siste utgivelse: {err}", "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Sikkerhetsnøkkel", "architecture": "Arkitektur", "sites": "Områder", "siteWgAnyClients": "Bruk hvilken som helst WireGuard klient til å koble til. Du må adressere interne ressurser ved hjelp av peer IP.", "siteWgCompatibleAllClients": "Kompatibel med alle WireGuard-klienter", "siteWgManualConfigurationRequired": "Manuell konfigurasjon påkrevd", "userErrorNotAdminOrOwner": "Bruker er ikke administrator eller eier", "pangolinSettings": "Innstillinger - Pangolin", "accessRoleYour": "Din rolle:", "accessRoleSelect2": "Velg roller", "accessUserSelect": "Velg brukere", "otpEmailEnter": "Skriv inn én e-post", "otpEmailEnterDescription": "Trykk enter for å legge til en e-post etter å ha tastet den inn i tekstfeltet.", "otpEmailErrorInvalid": "Ugyldig e-postadresse. Jokertegnet (*) må være hele lokaldelen.", "otpEmailSmtpRequired": "SMTP påkrevd", "otpEmailSmtpRequiredDescription": "SMTP må være aktivert på serveren for å bruke engangspassord-autentisering.", "otpEmailTitle": "Engangspassord", "otpEmailTitleDescription": "Krev e-postbasert autentisering for ressurstilgang", "otpEmailWhitelist": "E-post-hviteliste", "otpEmailWhitelistList": "Hvitlistede e-poster", "otpEmailWhitelistListDescription": "Kun brukere med disse e-postadressene vil ha tilgang til denne ressursen. De vil bli bedt om å skrive inn et engangspassord sendt til e-posten deres. Jokertegn (*@example.com) kan brukes for å tillate enhver e-postadresse fra et domene.", "otpEmailWhitelistSave": "Lagre hvitliste", "passwordAdd": "Legg til passord", "passwordRemove": "Fjern passord", "pincodeAdd": "Legg til PIN-kode", "pincodeRemove": "Fjern PIN-kode", "resourceAuthMethods": "Autentiseringsmetoder", "resourceAuthMethodsDescriptions": "Tillat tilgang til ressursen via ytterligere autentiseringsmetoder", "resourceAuthSettingsSave": "Lagret vellykket", "resourceAuthSettingsSaveDescription": "Autentiseringsinnstillinger er lagret", "resourceErrorAuthFetch": "Kunne ikke hente data", "resourceErrorAuthFetchDescription": "Det oppstod en feil ved henting av data", "resourceErrorPasswordRemove": "Feil ved fjerning av passord for ressurs", "resourceErrorPasswordRemoveDescription": "Det oppstod en feil ved fjerning av ressurspassordet", "resourceErrorPasswordSetup": "Feil ved innstilling av ressurspassord", "resourceErrorPasswordSetupDescription": "Det oppstod en feil ved innstilling av ressurspassordet", "resourceErrorPincodeRemove": "Feil ved fjerning av ressurs-PIN-koden", "resourceErrorPincodeRemoveDescription": "Det oppstod en feil under fjerning av ressurs-pinkoden", "resourceErrorPincodeSetup": "Feil ved innstilling av ressurs-PIN-kode", "resourceErrorPincodeSetupDescription": "Det oppstod en feil under innstilling av ressursens PIN-kode", "resourceErrorUsersRolesSave": "Klarte ikke å sette roller", "resourceErrorUsersRolesSaveDescription": "En feil oppstod ved innstilling av rollene", "resourceErrorWhitelistSave": "Feilet å lagre hvitliste", "resourceErrorWhitelistSaveDescription": "Det oppstod en feil under lagring av hvitlisten", "resourcePasswordSubmit": "Aktiver passordbeskyttelse", "resourcePasswordProtection": "Passordbeskyttelse {status}", "resourcePasswordRemove": "Ressurspassord fjernet", "resourcePasswordRemoveDescription": "Fjerning av ressurspassordet var vellykket", "resourcePasswordSetup": "Ressurspassord satt", "resourcePasswordSetupDescription": "Ressurspassordet har blitt vellykket satt", "resourcePasswordSetupTitle": "Angi passord", "resourcePasswordSetupTitleDescription": "Sett et passord for å beskytte denne ressursen", "resourcePincode": "PIN-kode", "resourcePincodeSubmit": "Aktiver PIN-kodebeskyttelse", "resourcePincodeProtection": "PIN-kodebeskyttelse {status}", "resourcePincodeRemove": "Ressurs PIN-kode fjernet", "resourcePincodeRemoveDescription": "Ressurspassordet ble fjernet", "resourcePincodeSetup": "Ressurs PIN-kode satt", "resourcePincodeSetupDescription": "Ressurs PIN-kode er satt vellykket", "resourcePincodeSetupTitle": "Angi PIN-kode", "resourcePincodeSetupTitleDescription": "Sett en pinkode for å beskytte denne ressursen", "resourceRoleDescription": "Administratorer har alltid tilgang til denne ressursen.", "resourceUsersRoles": "Tilgangskontroller", "resourceUsersRolesDescription": "Konfigurer hvilke brukere og roller som har tilgang til denne ressursen", "resourceUsersRolesSubmit": "Lagre tilgangskontroller", "resourceWhitelistSave": "Lagring vellykket", "resourceWhitelistSaveDescription": "Hvitlisteinnstillinger er lagret", "ssoUse": "Bruk plattform SSO", "ssoUseDescription": "Eksisterende brukere trenger kun å logge på én gang for alle ressurser som har dette aktivert.", "proxyErrorInvalidPort": "Ugyldig portnummer", "subdomainErrorInvalid": "Ugyldig underdomene", "domainErrorFetch": "Feil ved henting av domener", "domainErrorFetchDescription": "Det oppstod en feil ved henting av domenene", "resourceErrorUpdate": "Mislyktes å oppdatere ressurs", "resourceErrorUpdateDescription": "Det oppstod en feil under oppdatering av ressursen", "resourceUpdated": "Ressurs oppdatert", "resourceUpdatedDescription": "Ressursen er oppdatert vellykket", "resourceErrorTransfer": "Klarte ikke å overføre ressurs", "resourceErrorTransferDescription": "En feil oppsto under overføring av ressursen", "resourceTransferred": "Ressurs overført", "resourceTransferredDescription": "Ressursen er overført vellykket.", "resourceErrorToggle": "Feilet å veksle ressurs", "resourceErrorToggleDescription": "Det oppstod en feil ved oppdatering av ressursen", "resourceVisibilityTitle": "Synlighet", "resourceVisibilityTitleDescription": "Fullstendig aktiver eller deaktiver ressursynlighet", "resourceGeneral": "Generelle innstillinger", "resourceGeneralDescription": "Konfigurer de generelle innstillingene for denne ressursen", "resourceEnable": "Aktiver ressurs", "resourceTransfer": "Overfør Ressurs", "resourceTransferDescription": "Overfør denne ressursen til et annet område", "resourceTransferSubmit": "Overfør ressurs", "siteDestination": "Destinasjonsområde", "searchSites": "Søk områder", "countries": "Land", "accessRoleCreate": "Opprett rolle", "accessRoleCreateDescription": "Opprett en ny rolle for å gruppere brukere og administrere deres tillatelser.", "accessRoleEdit": "Rediger rolle", "accessRoleEditDescription": "Rediger rolleinformasjon.", "accessRoleCreateSubmit": "Opprett rolle", "accessRoleCreated": "Rolle opprettet", "accessRoleCreatedDescription": "Rollen er vellykket opprettet.", "accessRoleErrorCreate": "Klarte ikke å opprette rolle", "accessRoleErrorCreateDescription": "Det oppstod en feil under opprettelse av rollen.", "accessRoleUpdateSubmit": "Oppdater rolle", "accessRoleUpdated": "Rollen oppdatert", "accessRoleUpdatedDescription": "Rollen har blitt oppdatert.", "accessApprovalUpdated": "Godkjenning behandlet", "accessApprovalApprovedDescription": "Sett godkjenningsforespørsel om å godta.", "accessApprovalDeniedDescription": "Sett godkjenningsforespørsel om å nekte.", "accessRoleErrorUpdate": "Kunne ikke oppdatere rolle", "accessRoleErrorUpdateDescription": "Det oppstod en feil under oppdatering av rollen.", "accessApprovalErrorUpdate": "Kunne ikke behandle godkjenning", "accessApprovalErrorUpdateDescription": "Det oppstod en feil under behandling av godkjenningen.", "accessRoleErrorNewRequired": "Ny rolle kreves", "accessRoleErrorRemove": "Kunne ikke fjerne rolle", "accessRoleErrorRemoveDescription": "Det oppstod en feil under fjerning av rollen.", "accessRoleName": "Rollenavn", "accessRoleQuestionRemove": "Du er ferd med å slette rollen `{name}. Du kan ikke angre denne handlingen.", "accessRoleRemove": "Fjern Rolle", "accessRoleRemoveDescription": "Fjern en rolle fra organisasjonen", "accessRoleRemoveSubmit": "Fjern Rolle", "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", "licenseTierProfessional": "Profesjonell lisens", "licenseTierEnterprise": "Bedriftslisens", "licenseTierPersonal": "Personlig lisens", "licensed": "Lisensiert", "yes": "Ja", "no": "Nei", "sitesAdditional": "Ytterligere områder", "licenseKeys": "Lisensnøkler", "sitestCountDecrease": "Reduser antall områder", "sitestCountIncrease": "Øk antall områder", "idpManage": "Administrer Identitetsleverandører", "idpManageDescription": "Vis og administrer identitetsleverandører i systemet", "idpGlobalModeBanner": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdP (delt over alle organisasjoner). Administrer globale IdP'er i admin-panelet. For å aktivere IdP per organisasjon, rediger serverkonfigurasjonen og sett IdP-modus til org. Se dokumentasjonen. Hvis du vil fortsette å bruke globale IdPs og få denne til å forsvinne fra organisasjonens innstillinger, satt eksplisitt modusen til global i konfigurasjonen.", "idpGlobalModeBannerUpgradeRequired": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdPs (delt på tvers av alle organisasjoner). Administrer globale IdPs i administrasjons-panelet. For å bruke identitetsleverandører per organisasjon, må du oppgradere til Enterprise-utgaven.", "idpGlobalModeBannerLicenseRequired": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdPs (delt på tvers av alle organisasjoner). Administrer globale IdPs i administrasjons-panelet. For å bruke identitetsleverandører per organisasjon, kreves en Enterprise-lisens.", "idpDeletedDescription": "Identitetsleverandør slettet vellykket", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Er du sikker på at du vil slette identitetsleverandøren permanent?", "idpMessageRemove": "Dette vil fjerne identitetsleverandøren og alle tilhørende konfigurasjoner. Brukere som autentiserer seg via denne leverandøren vil ikke lenger kunne logge inn.", "idpMessageConfirm": "For å bekrefte, vennligst skriv inn navnet på identitetsleverandøren nedenfor.", "idpConfirmDelete": "Bekreft Sletting av Identitetsleverandør", "idpDelete": "Slett identitetsleverandør", "idp": "Identitetsleverandører", "idpSearch": "Søk identitetsleverandører...", "idpAdd": "Legg til Identitetsleverandør", "idpClientIdRequired": "Klient-ID er påkrevd.", "idpClientSecretRequired": "Klienthemmelighet er påkrevd.", "idpErrorAuthUrlInvalid": "Autentiserings-URL må være en gyldig URL.", "idpErrorTokenUrlInvalid": "Token-URL må være en gyldig URL.", "idpPathRequired": "Identifikatorbane er påkrevd.", "idpScopeRequired": "Omfang kreves.", "idpOidcDescription": "Konfigurer en OpenID Connect identitetsleverandør", "idpCreatedDescription": "Identitetsleverandør opprettet vellykket.", "idpCreate": "Opprett identitetsleverandør", "idpCreateDescription": "Konfigurer en ny identitetsleverandør for brukerautentisering", "idpSeeAll": "Se alle identitetsleverandører", "idpSettingsDescription": "Konfigurer grunnleggende informasjon for din identitetsleverandør", "idpDisplayName": "Et visningsnavn for denne identitetsleverandøren", "idpAutoProvisionUsers": "Automatisk brukerklargjøring", "idpAutoProvisionUsersDescription": "Når aktivert, opprettes brukere automatisk i systemet ved første innlogging, med mulighet til å tilordne brukere til roller og organisasjoner.", "licenseBadge": "EE", "idpType": "Leverandørtype", "idpTypeDescription": "Velg typen identitetsleverandør du ønsker å konfigurere", "idpOidcConfigure": "OAuth2/OIDC-konfigurasjon", "idpOidcConfigureDescription": "Konfigurer OAuth2/OIDC-leverandørens endepunkter og legitimasjon", "idpClientId": "Klient-ID", "idpClientIdDescription": "OAuth2 klient-ID fra identitet leverandøren", "idpClientSecret": "Klienthemmelighet", "idpClientSecretDescription": "Klient-hemmeligheten med OAuth2 fra identitet leverandøren", "idpAuthUrl": "Autorisasjons-URL", "idpAuthUrlDescription": "OAuth2 autorisasjonsendepunkt URL", "idpTokenUrl": "Token-URL", "idpTokenUrlDescription": "OAuth2-tokenendepunkt-URL", "idpOidcConfigureAlert": "Viktig informasjon", "idpOidcConfigureAlertDescription": "Etter at du har opprettet identitetsleverandøren, må du konfigurere callback-URLen i identitetsleverandørens innstillinger. Tilbakeringings URL vil bli lagt til etter vellykket oppretting.", "idpToken": "Token-konfigurasjon", "idpTokenDescription": "Konfigurer hvordan brukerinformasjon trekkes ut fra ID-tokenet", "idpJmespathAbout": "Om JMESPath", "idpJmespathAboutDescription": "Stiene nedenfor bruker JMESPath-syntaks for å hente ut verdier fra ID-tokenet.", "idpJmespathAboutDescriptionLink": "Lær mer om JMESPath", "idpJmespathLabel": "Identifikatorsti", "idpJmespathLabelDescription": "Stien til brukeridentifikatoren i ID-tokenet", "idpJmespathEmailPathOptional": "E-poststi (Valgfritt)", "idpJmespathEmailPathOptionalDescription": "Stien til brukerens e-postadresse i ID-tokenet", "idpJmespathNamePathOptional": "Navn Sti (Valgfritt)", "idpJmespathNamePathOptionalDescription": "Stien til brukerens navn i ID-tokenet", "idpOidcConfigureScopes": "Omfang", "idpOidcConfigureScopesDescription": "Mellomromseparert liste over OAuth2-omfang å be om", "idpSubmit": "Opprett identitetsleverandør", "orgPolicies": "Organisasjonsretningslinjer", "idpSettings": "{idpName} Innstillinger", "idpCreateSettingsDescription": "Konfigurer innstillingene for identiteten leverandøren", "roleMapping": "Rolletilordning", "orgMapping": "Organisasjon Kartlegging", "orgPoliciesSearch": "Søk i organisasjonens retningslinjer...", "orgPoliciesAdd": "Legg til organisasjonspolicy", "orgRequired": "Organisasjon er påkrevd", "error": "Feil", "success": "Suksess", "orgPolicyAddedDescription": "Policy vellykket lagt til", "orgPolicyUpdatedDescription": "Policyen er vellykket oppdatert", "orgPolicyDeletedDescription": "Policy slettet vellykket", "defaultMappingsUpdatedDescription": "Standardtilordninger oppdatert vellykket", "orgPoliciesAbout": "Om organisasjonens retningslinjer", "orgPoliciesAboutDescription": "Organisasjonspolicyer brukes til å kontrollere tilgang til organisasjoner basert på brukerens ID-token. Du kan spesifisere JMESPath-uttrykk for å trekke ut rolle- og organisasjonsinformasjon fra ID-tokenet.", "orgPoliciesAboutDescriptionLink": "Se dokumentasjon, for mer informasjon.", "defaultMappingsOptional": "Standard Tilordninger (Valgfritt)", "defaultMappingsOptionalDescription": "Standardtilordningene brukes når det ikke er definert en organisasjonspolicy for en organisasjon. Du kan spesifisere standard rolle- og organisasjonstilordninger som det kan falles tilbake på her.", "defaultMappingsRole": "Standard rolletilordning", "defaultMappingsRoleDescription": "Resultatet av dette uttrykket må returnere rollenavnet slik det er definert i organisasjonen som en streng.", "defaultMappingsOrg": "Standard organisasjonstilordning", "defaultMappingsOrgDescription": "Dette uttrykket må returnere organisasjons-ID-en eller «true» for å gi brukeren tilgang til organisasjonen.", "defaultMappingsSubmit": "Lagre standard tilordninger", "orgPoliciesEdit": "Rediger Organisasjonspolicy", "org": "Organisasjon", "orgSelect": "Velg organisasjon", "orgSearch": "Søk organisasjon", "orgNotFound": "Ingen organisasjon funnet.", "roleMappingPathOptional": "Rollekartleggingssti (Valgfritt)", "orgMappingPathOptional": "Organisasjonstilordningssti (Valgfritt)", "orgPolicyUpdate": "Oppdater policy", "orgPolicyAdd": "Legg til policy", "orgPolicyConfig": "Konfigurer tilgang for en organisasjon", "idpUpdatedDescription": "Identitetsleverandør vellykket oppdatert", "redirectUrl": "Omdirigerings-URL", "orgIdpRedirectUrls": "Omadressere URL'er", "redirectUrlAbout": "Om omdirigerings-URL", "redirectUrlAboutDescription": "Dette er URLen som brukere vil bli omdirigert etter autentisering. Du må konfigurere denne URLen i identitetsleverandørens innstillinger.", "pangolinAuth": "Autentisering - Pangolin", "verificationCodeLengthRequirements": "Din verifiseringskode må være 8 tegn.", "errorOccurred": "Det oppstod en feil", "emailErrorVerify": "Kunne ikke verifisere e-post:", "emailVerified": "E-posten er bekreftet! Omdirigerer deg...", "verificationCodeErrorResend": "Kunne ikke sende bekreftelseskode på nytt:", "verificationCodeResend": "Bekreftelseskode sendt på nytt", "verificationCodeResendDescription": "Vi har sendt en ny bekreftelseskode til e-postadressen din. Vennligst sjekk innboksen din.", "emailVerify": "Verifiser e-post", "emailVerifyDescription": "Skriv inn bekreftelseskoden sendt til e-postadressen din.", "verificationCode": "Verifiseringskode", "verificationCodeEmailSent": "Vi har sendt en bekreftelseskode til e-postadressen din.", "submit": "Send inn", "emailVerifyResendProgress": "Sender på nytt...", "emailVerifyResend": "Har du ikke mottatt en kode? Klikk her for å sende på nytt", "passwordNotMatch": "Passordene stemmer ikke", "signupError": "Det oppsto en feil ved registrering", "pangolinLogoAlt": "Pangolin Logo", "inviteAlready": "Ser ut til at du har blitt invitert!", "inviteAlreadyDescription": "For å godta invitasjonen, må du logge inn eller opprette en konto.", "signupQuestion": "Har du allerede en konto?", "login": "Logg inn", "resourceNotFound": "Ressurs ikke funnet", "resourceNotFoundDescription": "Ressursen du prøver å få tilgang til eksisterer ikke.", "pincodeRequirementsLength": "PIN må være nøyaktig 6 siffer", "pincodeRequirementsChars": "PIN må kun inneholde tall", "passwordRequirementsLength": "Passord må være minst 1 tegn langt", "passwordRequirementsTitle": "Passordkrav:", "passwordRequirementLength": "Minst 8 tegn lang", "passwordRequirementUppercase": "Minst én stor bokstav", "passwordRequirementLowercase": "Minst én liten bokstav", "passwordRequirementNumber": "Minst ét tall", "passwordRequirementSpecial": "Minst ett spesialtegn", "passwordRequirementsMet": "✓ Passord oppfyller alle krav", "passwordStrength": "Passordstyrke", "passwordStrengthWeak": "Svakt", "passwordStrengthMedium": "Medium", "passwordStrengthStrong": "Sterkt", "passwordRequirements": "Krav:", "passwordRequirementLengthText": "8+ tegn", "passwordRequirementUppercaseText": "Stor bokstav (A-Z)", "passwordRequirementLowercaseText": "Liten bokstav (a-z)", "passwordRequirementNumberText": "Tall (0-9)", "passwordRequirementSpecialText": "Spesialtegn (!@#$%...)", "passwordsDoNotMatch": "Passordene stemmer ikke", "otpEmailRequirementsLength": "OTP må være minst 1 tegn lang.", "otpEmailSent": "OTP sendt", "otpEmailSentDescription": "En OTP er sendt til din e-post", "otpEmailErrorAuthenticate": "Mislyktes å autentisere med e-post", "pincodeErrorAuthenticate": "Kunne ikke autentisere med pinkode", "passwordErrorAuthenticate": "Kunne ikke autentisere med passord", "poweredBy": "Drevet av", "authenticationRequired": "Autentisering påkrevd", "authenticationMethodChoose": "Velg din foretrukne metode for å få tilgang til {name}", "authenticationRequest": "Du må autentisere deg for å få tilgang til {name}", "user": "Bruker", "pincodeInput": "6-sifret PIN-kode", "pincodeSubmit": "Logg inn med PIN", "passwordSubmit": "Logg inn med passord", "otpEmailDescription": "En engangskode vil bli sendt til denne e-posten.", "otpEmailSend": "Send engangskode", "otpEmail": "Engangspassord (OTP)", "otpEmailSubmit": "Send inn OTP", "backToEmail": "Tilbake til E-post", "noSupportKey": "Serveren kjører uten en supporterlisens. Vurder å støtte prosjektet!", "accessDenied": "Tilgang nektet", "accessDeniedDescription": "Du har ikke tilgang til denne ressursen. Hvis dette er en feil, vennligst kontakt administratoren.", "accessTokenError": "Feil ved sjekk av tilgangstoken", "accessGranted": "Tilgang gitt", "accessUrlInvalid": "Ugyldig tilgangs-URL", "accessGrantedDescription": "Du har fått tilgang til denne ressursen. Omdirigerer deg...", "accessUrlInvalidDescription": "Denne delings-URL-en er ugyldig. Vennligst kontakt ressurseieren for en ny URL.", "tokenInvalid": "Ugyldig token", "pincodeInvalid": "Ugyldig kode", "passwordErrorRequestReset": "Forespørsel om tilbakestilling mislyktes", "passwordErrorReset": "Klarte ikke å tilbakestille passord:", "passwordResetSuccess": "Passordet er tilbakestilt! Går tilbake til innlogging...", "passwordReset": "Tilbakestill passord", "passwordResetDescription": "Følg stegene for å tilbakestille passordet ditt", "passwordResetSent": "Vi sender en kode for tilbakestilling av passord til denne e-postadressen.", "passwordResetCode": "Tilbakestillingskode", "passwordResetCodeDescription": "Sjekk e-posten din for tilbakestillingskoden.", "generatePasswordResetCode": "Lag tilbakestillingskode for passord", "passwordResetCodeGenerated": "Passord tilbakestillingskoden er generert", "passwordResetCodeGeneratedDescription": "Del denne koden med brukeren. De kan bruke den til å tilbakestille passordet.", "passwordResetUrl": "Reset URL", "passwordNew": "Nytt passord", "passwordNewConfirm": "Bekreft nytt passord", "changePassword": "Endre passord", "changePasswordDescription": "Oppdater passordet for din konto", "oldPassword": "Nåværende passord", "newPassword": "Nytt passord", "confirmNewPassword": "Bekreft nytt passord", "changePasswordError": "Kunne ikke endre passord", "changePasswordErrorDescription": "Det oppstod en feil under endring av passordet", "changePasswordSuccess": "Passordet er endret", "changePasswordSuccessDescription": "Ditt passord ble oppdatert", "passwordExpiryRequired": "Passordutløp kreves", "passwordExpiryDescription": "Denne organisasjonen krever at du bytter passord hver {maxDays} dag.", "changePasswordNow": "Bytt passord nå", "pincodeAuth": "Autentiseringskode", "pincodeSubmit2": "Send kode", "passwordResetSubmit": "Be om tilbakestilling", "passwordResetAlreadyHaveCode": "Skriv inn koden", "passwordResetSmtpRequired": "Kontakt din administrator", "passwordResetSmtpRequiredDescription": "En passord tilbakestillingskode kreves for å tilbakestille passordet. Kontakt systemansvarlig for assistanse.", "passwordBack": "Tilbake til passord", "loginBack": "Gå tilbake til innloggingssiden for hovedkontoen", "signup": "Registrer deg", "loginStart": "Logg inn for å komme i gang", "idpOidcTokenValidating": "Validerer OIDC-token", "idpOidcTokenResponse": "Valider OIDC-tokensvar", "idpErrorOidcTokenValidating": "Feil ved validering av OIDC-token", "idpConnectingTo": "Kobler til {name}", "idpConnectingToDescription": "Validerer identiteten din", "idpConnectingToProcess": "Kobler til...", "idpConnectingToFinished": "Tilkoblet", "idpErrorConnectingTo": "Det oppstod et problem med å koble til {name}. Vennligst kontakt din administrator.", "idpErrorNotFound": "IdP ikke funnet", "inviteInvalid": "Ugyldig invitasjon", "inviteInvalidDescription": "Invitasjonslenken er ugyldig.", "inviteErrorWrongUser": "Invitasjonen er ikke for denne brukeren", "inviteErrorUserNotExists": "Brukeren eksisterer ikke. Vennligst opprett en konto først.", "inviteErrorLoginRequired": "Du må være logget inn for å godta en invitasjon", "inviteErrorExpired": "Invitasjonen kan ha utløpt", "inviteErrorRevoked": "Invitasjonen kan ha blitt trukket tilbake", "inviteErrorTypo": "Det kan være en skrivefeil i invitasjonslenken", "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.", "overview": "Oversikt", "home": "Hjem", "settings": "Innstillinger", "usersAll": "Alle brukere", "license": "Lisens", "pangolinDashboard": "Dashbord - Pangolin", "noResults": "Ingen resultater funnet.", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "Inntastede tagger", "tagsEnteredDescription": "Dette er taggene du har tastet inn.", "tagsWarnCannotBeLessThanZero": "maxTags og minTags kan ikke være mindre enn 0", "tagsWarnNotAllowedAutocompleteOptions": "Tagg ikke tillatt i henhold til autofullfør-alternativer", "tagsWarnInvalid": "Ugyldig tagg i henhold til validateTag", "tagWarnTooShort": "Tagg {tagText} er for kort", "tagWarnTooLong": "Tagg {tagText} er for lang", "tagsWarnReachedMaxNumber": "Maksimalt antall tillatte tagger er nådd", "tagWarnDuplicate": "Duplisert tagg {tagText} ble ikke lagt til", "supportKeyInvalid": "Ugyldig nøkkel", "supportKeyInvalidDescription": "Din supporternøkkel er ugyldig.", "supportKeyValid": "Gyldig nøkkel", "supportKeyValidDescription": "Din supporternøkkel er validert. Takk for din støtte!", "supportKeyErrorValidationDescription": "Klarte ikke å validere supporternøkkel.", "supportKey": "Støtt utviklingen og adopter en Pangolin!", "supportKeyDescription": "Kjøp en supporternøkkel for å hjelpe oss med å fortsette utviklingen av Pangolin for fellesskapet. Ditt bidrag lar oss bruke mer tid på å vedlikeholde og legge til nye funksjoner i applikasjonen for alle. Vi vil aldri bruke dette til å legge funksjoner bak en betalingsmur. Dette er atskilt fra enhver kommersiell utgave.", "supportKeyPet": "Du vil også få adoptere og møte din helt egen kjæledyr-Pangolin!", "supportKeyPurchase": "Betalinger behandles via GitHub. Etterpå kan du hente nøkkelen din på", "supportKeyPurchaseLink": "vår nettside", "supportKeyPurchase2": "og løse den inn her.", "supportKeyLearnMore": "Lær mer.", "supportKeyOptions": "Vennligst velg det alternativet som passer deg best.", "supportKetOptionFull": "Full støttespiller", "forWholeServer": "For hele serveren", "lifetimePurchase": "Livstidskjøp", "supporterStatus": "Supporterstatus", "buy": "Kjøp", "supportKeyOptionLimited": "Begrenset støttespiller", "forFiveUsers": "For 5 eller færre brukere", "supportKeyRedeem": "Løs inn supporternøkkel", "supportKeyHideSevenDays": "Skjul i 7 dager", "supportKeyEnter": "Skriv inn supporternøkkel", "supportKeyEnterDescription": "Møt din helt egen kjæledyr-Pangolin!", "githubUsername": "GitHub-brukernavn", "supportKeyInput": "Supporternøkkel", "supportKeyBuy": "Kjøp supporternøkkel", "logoutError": "Feil ved utlogging", "signingAs": "Logget inn som", "serverAdmin": "Serveradministrator", "managedSelfhosted": "Administrert selv-hostet", "otpEnable": "Aktiver tofaktor", "otpDisable": "Deaktiver tofaktor", "logout": "Logg ut", "licenseTierProfessionalRequired": "Profesjonell utgave påkrevd", "licenseTierProfessionalRequiredDescription": "Denne funksjonen er kun tilgjengelig i den profesjonelle utgaven.", "actionGetOrg": "Hent organisasjon", "updateOrgUser": "Oppdater org.bruker", "createOrgUser": "Opprett Org bruker", "actionUpdateOrg": "Oppdater organisasjon", "actionRemoveInvitation": "Fjern invitasjon", "actionUpdateUser": "Oppdater bruker", "actionGetUser": "Hent bruker", "actionGetOrgUser": "Hent organisasjonsbruker", "actionListOrgDomains": "List opp organisasjonsdomener", "actionGetDomain": "Få Domene", "actionCreateOrgDomain": "Opprett domene", "actionUpdateOrgDomain": "Oppdater domene", "actionDeleteOrgDomain": "Slett domene", "actionGetDNSRecords": "Hent DNS-oppføringer", "actionRestartOrgDomain": "Omstart Domene", "actionCreateSite": "Opprett område", "actionDeleteSite": "Slett område", "actionGetSite": "Hent område", "actionListSites": "List opp områder", "actionApplyBlueprint": "Bruk blåkopi", "actionListBlueprints": "List opp blåkopier", "actionGetBlueprint": "Hent blåkopi", "setupToken": "Oppsetttoken", "setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.", "setupTokenRequired": "Oppsetttoken er nødvendig", "actionUpdateSite": "Oppdater område", "actionListSiteRoles": "List opp tillatte områderoller", "actionCreateResource": "Opprett ressurs", "actionDeleteResource": "Slett ressurs", "actionGetResource": "Hent ressurs", "actionListResource": "List opp ressurser", "actionUpdateResource": "Oppdater ressurs", "actionListResourceUsers": "List opp ressursbrukere", "actionSetResourceUsers": "Angi ressursbrukere", "actionSetAllowedResourceRoles": "Angi tillatte ressursroller", "actionListAllowedResourceRoles": "List opp tillatte ressursroller", "actionSetResourcePassword": "Angi ressurspassord", "actionSetResourcePincode": "Angi ressurspinkode", "actionSetResourceEmailWhitelist": "Angi e-post-hviteliste for ressurs", "actionGetResourceEmailWhitelist": "Hent e-post-hviteliste for ressurs", "actionCreateTarget": "Opprett mål", "actionDeleteTarget": "Slett mål", "actionGetTarget": "Hent mål", "actionListTargets": "List opp mål", "actionUpdateTarget": "Oppdater mål", "actionCreateRole": "Opprett rolle", "actionDeleteRole": "Slett rolle", "actionGetRole": "Hent rolle", "actionListRole": "List opp roller", "actionUpdateRole": "Oppdater rolle", "actionListAllowedRoleResources": "List opp tillatte rolleressurser", "actionInviteUser": "Inviter bruker", "actionRemoveUser": "Fjern bruker", "actionListUsers": "List opp brukere", "actionAddUserRole": "Legg til brukerrolle", "actionGenerateAccessToken": "Generer tilgangstoken", "actionDeleteAccessToken": "Slett tilgangstoken", "actionListAccessTokens": "List opp tilgangstokener", "actionCreateResourceRule": "Opprett ressursregel", "actionDeleteResourceRule": "Slett ressursregel", "actionListResourceRules": "List opp ressursregler", "actionUpdateResourceRule": "Oppdater ressursregel", "actionListOrgs": "List opp organisasjoner", "actionCheckOrgId": "Sjekk ID", "actionCreateOrg": "Opprett organisasjon", "actionDeleteOrg": "Slett organisasjon", "actionListApiKeys": "List opp API-nøkler", "actionListApiKeyActions": "List opp API-nøkkelhandlinger", "actionSetApiKeyActions": "Angi tillatte handlinger for API-nøkkel", "actionCreateApiKey": "Opprett API-nøkkel", "actionDeleteApiKey": "Slett API-nøkkel", "actionCreateIdp": "Opprett IDP", "actionUpdateIdp": "Oppdater IDP", "actionDeleteIdp": "Slett IDP", "actionListIdps": "List opp IDP-er", "actionGetIdp": "Hent IDP", "actionCreateIdpOrg": "Opprett IDP-organisasjonspolicy", "actionDeleteIdpOrg": "Slett IDP-organisasjonspolicy", "actionListIdpOrgs": "List opp IDP-organisasjoner", "actionUpdateIdpOrg": "Oppdater IDP-organisasjon", "actionCreateClient": "Opprett Klient", "actionDeleteClient": "Slett klient", "actionArchiveClient": "Arkiver klient", "actionUnarchiveClient": "Fjern arkivering klient", "actionBlockClient": "Blokker kunde", "actionUnblockClient": "Avblokker klient", "actionUpdateClient": "Oppdater klient", "actionListClients": "List klienter", "actionGetClient": "Hent klient", "actionCreateSiteResource": "Opprett stedsressurs", "actionDeleteSiteResource": "Slett Stedsressurs", "actionGetSiteResource": "Hent Stedsressurs", "actionListSiteResources": "List opp Stedsressurser", "actionUpdateSiteResource": "Oppdater Stedsressurs", "actionListInvitations": "Liste invitasjoner", "actionExportLogs": "Eksportlogger", "actionViewLogs": "Vis logger", "noneSelected": "Ingen valgt", "orgNotFound2": "Ingen organisasjoner funnet.", "searchPlaceholder": "Søk...", "emptySearchOptions": "Ingen valg funnet", "create": "Opprett", "orgs": "Organisasjoner", "loginError": "En uventet feil oppstod. Vennligst prøv igjen.", "loginRequiredForDevice": "Innlogging er nødvendig for enheten din.", "passwordForgot": "Glemt passordet ditt?", "otpAuth": "Tofaktorautentisering", "otpAuthDescription": "Skriv inn koden fra autentiseringsappen din eller en av dine engangs reservekoder.", "otpAuthSubmit": "Send inn kode", "idpContinue": "Eller fortsett med", "otpAuthBack": "Tilbake til passord", "navbar": "Navigasjonsmeny", "navbarDescription": "Hovednavigasjonsmeny for applikasjonen", "navbarDocsLink": "Dokumentasjon", "otpErrorEnable": "Kunne ikke aktivere 2FA", "otpErrorEnableDescription": "En feil oppstod under aktivering av 2FA", "otpSetupCheckCode": "Vennligst skriv inn en 6-sifret kode", "otpSetupCheckCodeRetry": "Ugyldig kode. Vennligst prøv igjen.", "otpSetup": "Aktiver tofaktorautentisering", "otpSetupDescription": "Sikre kontoen din med et ekstra lag med beskyttelse", "otpSetupScanQr": "Skann denne QR-koden med autentiseringsappen din eller skriv inn den hemmelige nøkkelen manuelt:", "otpSetupSecretCode": "Autentiseringskode", "otpSetupSuccess": "Tofaktorautentisering aktivert", "otpSetupSuccessStoreBackupCodes": "Kontoen din er nå sikrere. Ikke glem å lagre reservekodene dine.", "otpErrorDisable": "Kunne ikke deaktivere 2FA", "otpErrorDisableDescription": "En feil oppstod under deaktivering av 2FA", "otpRemove": "Deaktiver tofaktorautentisering", "otpRemoveDescription": "Deaktiver tofaktorautentisering for kontoen din", "otpRemoveSuccess": "Tofaktorautentisering deaktivert", "otpRemoveSuccessMessage": "Tofaktorautentisering er deaktivert for kontoen din. Du kan aktivere den igjen når som helst.", "otpRemoveSubmit": "Deaktiver 2FA", "paginator": "Side {current} av {last}", "paginatorToFirst": "Gå til første side", "paginatorToPrevious": "Gå til forrige side", "paginatorToNext": "Gå til neste side", "paginatorToLast": "Gå til siste side", "copyText": "Kopier tekst", "copyTextFailed": "Klarte ikke å kopiere tekst: ", "copyTextClipboard": "Kopier til utklippstavle", "inviteErrorInvalidConfirmation": "Ugyldig bekreftelse", "passwordRequired": "Passord er påkrevd", "allowAll": "Tillat alle", "permissionsAllowAll": "Tillat alle rettigheter", "githubUsernameRequired": "GitHub-brukernavn er påkrevd", "supportKeyRequired": "supporternøkkel er påkrevd", "passwordRequirementsChars": "Passordet må være minst 8 tegn", "language": "Språk", "verificationCodeRequired": "Kode er påkrevd", "userErrorNoUpdate": "Ingen bruker å oppdatere", "siteErrorNoUpdate": "Ingen område å oppdatere", "resourceErrorNoUpdate": "Ingen ressurs å oppdatere", "authErrorNoUpdate": "Ingen autentiseringsinfo å oppdatere", "orgErrorNoUpdate": "Ingen organisasjon å oppdatere", "orgErrorNoProvided": "Ingen organisasjon angitt", "apiKeysErrorNoUpdate": "Ingen API-nøkkel å oppdatere", "sidebarOverview": "Oversikt", "sidebarHome": "Hjem", "sidebarSites": "Områder", "sidebarApprovals": "Godkjenningsforespørsler", "sidebarResources": "Ressurser", "sidebarProxyResources": "Offentlig", "sidebarClientResources": "Privat", "sidebarAccessControl": "Tilgangskontroll", "sidebarLogsAndAnalytics": "Logger og analyser", "sidebarTeam": "Lag", "sidebarUsers": "Brukere", "sidebarAdmin": "Administrator", "sidebarInvitations": "Invitasjoner", "sidebarRoles": "Roller", "sidebarShareableLinks": "Lenker", "sidebarApiKeys": "API-nøkler", "sidebarSettings": "Innstillinger", "sidebarAllUsers": "Alle brukere", "sidebarIdentityProviders": "Identitetsleverandører", "sidebarLicense": "Lisens", "sidebarClients": "Klienter", "sidebarUserDevices": "Bruker Enheter", "sidebarMachineClients": "Maskiner", "sidebarDomains": "Domener", "sidebarGeneral": "Administrer", "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", "blueprintAdd": "Legg til blåkopi", "blueprintGoBack": "Se alle blåkopier", "blueprintCreate": "Opprette mal", "blueprintCreateDescription2": "Følg trinnene nedenfor for å opprette og bruke en ny plantegning", "blueprintDetails": "Blåkopi detaljer", "blueprintDetailsDescription": "Se resultatet av den påførte blåkopien og alle feil som oppstod", "blueprintInfo": "Blåkopi informasjon", "message": "Melding", "blueprintContentsDescription": "Definere innholdet til YAML som beskriver infrastrukturen", "blueprintErrorCreateDescription": "Det oppstod en feil da plantegningen ble lagt til", "blueprintErrorCreate": "Feil ved opprettelse av plantegning", "searchBlueprintProgress": "Søk etter plantegninger...", "appliedAt": "Anvendt på", "source": "Kilde", "contents": "Innhold", "parsedContents": "Parastinnhold (kun lese)", "enableDockerSocket": "Aktiver Docker blåkopi", "enableDockerSocketDescription": "Aktiver skraping av Docker Socket for blueprint Etiketter. Socket bane må brukes for nye.", "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.", "containerName": "Navn", "containerImage": "Bilde", "containerState": "Tilstand", "containerNetworks": "Nettverk", "containerHostnameIp": "Vertsnavn/IP", "containerLabels": "Etiketter", "containerLabelsCount": "{count, plural, one {en etikett} other {# etiketter}}", "containerLabelsTitle": "Containeretiketter", "containerLabelEmpty": "", "containerPorts": "Porter", "containerPortsMore": "+{count} til", "containerActions": "Handlinger", "select": "Velg", "noContainersMatchingFilters": "Ingen containere funnet som matcher de nåværende filtrene.", "showContainersWithoutPorts": "Vis containere uten porter", "showStoppedContainers": "Vis stoppede containere", "noContainersFound": "Ingen containere funnet. Sørg for at Docker-containere kjører.", "searchContainersPlaceholder": "Søk blant {count} containere...", "searchResultsCount": "{count, plural, one {ett resultat} other {# resultater}}", "filters": "Filtre", "filterOptions": "Filteralternativer", "filterPorts": "Porter", "filterStopped": "Stoppet", "clearAllFilters": "Fjern alle filtre", "columns": "Kolonner", "toggleColumns": "Vis/skjul kolonner", "refreshContainersList": "Oppdater containerliste", "searching": "Søker...", "noContainersFoundMatching": "Ingen containere funnet som matcher \"{filter}\".", "light": "lys", "dark": "mørk", "system": "system", "theme": "Tema", "subnetRequired": "Subnett er påkrevd", "initialSetupTitle": "Førstegangsoppsett av server", "initialSetupDescription": "Opprett den første serveradministratorkontoen. Det kan bare finnes én serveradministrator. Du kan alltid endre denne påloggingsinformasjonen senere.", "createAdminAccount": "Opprett administratorkonto", "setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.", "certificateStatus": "Sertifikatstatus", "loading": "Laster inn", "loadingAnalytics": "Laster inn analyser", "restart": "Start på nytt", "domains": "Domener", "domainsDescription": "Opprett og behandle domener som er tilgjengelige i organisasjonen", "domainsSearch": "Søk i domener...", "domainAdd": "Legg til domene", "domainAddDescription": "Registrer et nytt domene med organisasjonen", "domainCreate": "Opprett domene", "domainCreatedDescription": "Domene ble opprettet", "domainDeletedDescription": "Domene ble slettet", "domainQuestionRemove": "Er du sikker på at du vil fjerne domenet?", "domainMessageRemove": "Når domenet er fjernet, vil det ikke lenger være forbundet med organisasjonen.", "domainConfirmDelete": "Bekreft sletting av domene", "domainDelete": "Slett domene", "domain": "Domene", "selectDomainTypeNsName": "Domenedelegering (NS)", "selectDomainTypeNsDescription": "Dette domenet og alle dets underdomener. Bruk dette når du vil kontrollere en hel domenesone.", "selectDomainTypeCnameName": "Enkelt domene (CNAME)", "selectDomainTypeCnameDescription": "Bare dette spesifikke domenet. Bruk dette for individuelle underdomener eller spesifikke domeneoppføringer.", "selectDomainTypeWildcardName": "Wildcard-domene", "selectDomainTypeWildcardDescription": "Dette domenet og dets underdomener.", "domainDelegation": "Enkelt domene", "selectType": "Velg en type", "actions": "Handlinger", "refresh": "Oppdater", "refreshError": "Klarte ikke å oppdatere data", "verified": "Verifisert", "pending": "Venter", "pendingApproval": "Venter på godkjenning", "sidebarBilling": "Fakturering", "billing": "Fakturering", "orgBillingDescription": "Administrer faktureringsinformasjon og abonnementer", "github": "GitHub", "pangolinHosted": "Driftet av Pangolin", "fossorial": "Fossorial", "completeAccountSetup": "Fullfør kontooppsett", "completeAccountSetupDescription": "Angi passordet ditt for å komme i gang", "accountSetupSent": "Vi sender en oppsettskode for kontoen til denne e-postadressen.", "accountSetupCode": "Oppsettskode", "accountSetupCodeDescription": "Sjekk e-posten din for oppsettskoden.", "passwordCreate": "Opprett passord", "passwordCreateConfirm": "Bekreft passord", "accountSetupSubmit": "Send oppsettskode", "completeSetup": "Fullfør oppsett", "accountSetupSuccess": "Kontooppsett fullført! Velkommen til Pangolin!", "documentation": "Dokumentasjon", "saveAllSettings": "Lagre alle innstillinger", "saveResourceTargets": "Lagre mål", "saveResourceHttp": "Lagre proxy-innstillinger", "saveProxyProtocol": "Lagre proxy-protokollinnstillinger", "settingsUpdated": "Innstillinger oppdatert", "settingsUpdatedDescription": "Innstillinger oppdatert vellykket", "settingsErrorUpdate": "Klarte ikke å oppdatere innstillinger", "settingsErrorUpdateDescription": "En feil oppstod under oppdatering av innstillinger", "sidebarCollapse": "Skjul", "sidebarExpand": "Utvid", "productUpdateMoreInfo": "{noOfUpdates} flere oppdateringer", "productUpdateInfo": "{noOfUpdates} oppdateringer", "productUpdateWhatsNew": "Hva er nytt", "productUpdateTitle": "Oppdateringer om produktet", "productUpdateEmpty": "Ingen oppdateringer", "dismissAll": "Avvis alle", "pangolinUpdateAvailable": "Oppdatering tilgjengelig", "pangolinUpdateAvailableInfo": "Versjon {version} er klar til å installere", "pangolinUpdateAvailableReleaseNotes": "Se utgivelsesnotater", "newtUpdateAvailable": "Oppdatering tilgjengelig", "newtUpdateAvailableInfo": "En ny versjon av Newt er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.", "domainPickerEnterDomain": "Domene", "domainPickerPlaceholder": "minapp.eksempel.no", "domainPickerDescription": "Skriv inn hele domenet til ressursen for å se tilgjengelige alternativer.", "domainPickerDescriptionSaas": "Skriv inn et fullt domene, underdomene eller bare et navn for å se tilgjengelige alternativer", "domainPickerTabAll": "Alle", "domainPickerTabOrganization": "Organisasjon", "domainPickerTabProvided": "Levert", "domainPickerSortAsc": "A-Å", "domainPickerSortDesc": "Å-A", "domainPickerCheckingAvailability": "Sjekker tilgjengelighet...", "domainPickerNoMatchingDomains": "Ingen samsvarende domener funnet. Prøv et annet domene eller kontroller organisasjonens domeneinnstillinger.", "domainPickerOrganizationDomains": "Organisasjonsdomener", "domainPickerProvidedDomains": "Leverte domener", "domainPickerSubdomain": "Underdomene: {subdomain}", "domainPickerNamespace": "Navnerom: {namespace}", "domainPickerShowMore": "Vis mer", "regionSelectorTitle": "Velg Region", "regionSelectorInfo": "Å velge en region hjelper oss med å gi bedre ytelse for din lokasjon. Du trenger ikke være i samme region som serveren.", "regionSelectorPlaceholder": "Velg en region", "regionSelectorComingSoon": "Kommer snart", "billingLoadingSubscription": "Laster abonnement...", "billingFreeTier": "Gratis nivå", "billingWarningOverLimit": "Advarsel: Du har overskredet en eller flere bruksgrenser. Nettstedene dine vil ikke koble til før du endrer abonnementet ditt eller justerer bruken.", "billingUsageLimitsOverview": "Oversikt over bruksgrenser", "billingMonitorUsage": "Overvåk bruken din i forhold til konfigurerte grenser. Hvis du trenger økte grenser, vennligst kontakt support@pangolin.net.", "billingDataUsage": "Databruk", "billingSites": "Områder", "billingUsers": "Brukere", "billingDomains": "Domener", "billingOrganizations": "Orger", "billingRemoteExitNodes": "Eksterne Noder", "billingNoLimitConfigured": "Ingen grense konfigurert", "billingEstimatedPeriod": "Estimert faktureringsperiode", "billingIncludedUsage": "Inkludert Bruk", "billingIncludedUsageDescription": "Bruk inkludert i din nåværende abonnementsplan", "billingFreeTierIncludedUsage": "Gratis nivå bruksgrenser", "billingIncluded": "inkludert", "billingEstimatedTotal": "Estimert Totalt:", "billingNotes": "Notater", "billingEstimateNote": "Dette er et estimat basert på din nåværende bruk.", "billingActualChargesMayVary": "Faktiske kostnader kan variere.", "billingBilledAtEnd": "Du vil bli fakturert ved slutten av faktureringsperioden.", "billingModifySubscription": "Endre abonnement", "billingStartSubscription": "Start abonnement", "billingRecurringCharge": "Innkommende Avgift", "billingManageSubscriptionSettings": "Administrer abonnementsinnstillinger og -innstillinger", "billingNoActiveSubscription": "Du har ikke et aktivt abonnement. Start abonnementet ditt for å øke bruksgrensene.", "billingFailedToLoadSubscription": "Klarte ikke å laste abonnement", "billingFailedToLoadUsage": "Klarte ikke å laste bruksdata", "billingFailedToGetCheckoutUrl": "Mislyktes å få betalingslenke", "billingPleaseTryAgainLater": "Vennligst prøv igjen senere.", "billingCheckoutError": "Kasserror", "billingFailedToGetPortalUrl": "Mislyktes å hente portal URL", "billingPortalError": "Portalfeil", "billingDataUsageInfo": "Du er ladet for all data som overføres gjennom dine sikre tunneler når du er koblet til skyen. Dette inkluderer både innkommende og utgående trafikk på alle dine nettsteder. Når du når grensen din, vil sidene koble fra til du oppgraderer planen eller reduserer bruken. Data belastes ikke ved bruk av EK-grupper.", "billingSInfo": "Hvor mange nettsteder du kan bruke", "billingUsersInfo": "Hvor mange brukere du kan bruke", "billingDomainInfo": "Hvor mange domener du kan bruke", "billingRemoteExitNodesInfo": "Hvor mange fjernnoder du kan bruke", "billingLicenseKeys": "Lisensnøkler", "billingLicenseKeysDescription": "Administrer dine lisensnøkkelabonnementer", "billingLicenseSubscription": "Lisens abonnement", "billingInactive": "Inaktiv", "billingLicenseItem": "Lisens artikkel", "billingQuantity": "Antall", "billingTotal": "totalt", "billingModifyLicenses": "Endre lisensabonnement", "domainNotFound": "Domene ikke funnet", "domainNotFoundDescription": "Denne ressursen er deaktivert fordi domenet ikke lenger eksisterer i systemet vårt. Vennligst angi et nytt domene for denne ressursen.", "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", "securityKeyRegister": "Registrer ny sikkerhetsnøkkel", "securityKeyList": "Dine sikkerhetsnøkler", "securityKeyNone": "Ingen sikkerhetsnøkler er registrert enda", "securityKeyNameRequired": "Navn er påkrevd", "securityKeyRemove": "Fjern", "securityKeyLastUsed": "Sist brukt: {date}", "securityKeyNameLabel": "Navn på sikkerhetsnøkkel", "securityKeyRegisterSuccess": "Sikkerhetsnøkkel registrert", "securityKeyRegisterError": "Klarte ikke å registrere sikkerhetsnøkkel", "securityKeyRemoveSuccess": "Sikkerhetsnøkkel fjernet", "securityKeyRemoveError": "Klarte ikke å fjerne sikkerhetsnøkkel", "securityKeyLoadError": "Klarte ikke å laste inn sikkerhetsnøkler", "securityKeyLogin": "Bruk sikkerhetsnøkkel", "securityKeyAuthError": "Klarte ikke å autentisere med sikkerhetsnøkkel", "securityKeyRecommendation": "Registrer en reservesikkerhetsnøkkel på en annen enhet for å sikre at du alltid har tilgang til kontoen din.", "registering": "Registrerer...", "securityKeyPrompt": "Vennligst verifiser identiteten din med sikkerhetsnøkkelen. Sørg for at sikkerhetsnøkkelen er koblet til og klar.", "securityKeyBrowserNotSupported": "Nettleseren din støtter ikke sikkerhetsnøkler. Vennligst bruk en moderne nettleser som Chrome, Firefox eller Safari.", "securityKeyPermissionDenied": "Vennligst tillat tilgang til sikkerhetsnøkkelen din for å fortsette innloggingen.", "securityKeyRemovedTooQuickly": "Vennligst hold sikkerhetsnøkkelen tilkoblet til innloggingsprosessen er fullført.", "securityKeyNotSupported": "Sikkerhetsnøkkelen din er kanskje ikke kompatibel. Vennligst prøv en annen sikkerhetsnøkkel.", "securityKeyUnknownError": "Det oppstod et problem med å bruke sikkerhetsnøkkelen din. Vennligst prøv igjen.", "twoFactorRequired": "Tofaktorautentisering er påkrevd for å registrere en sikkerhetsnøkkel.", "twoFactor": "Tofaktorautentisering", "twoFactorAuthentication": "To-faktor autentisering", "twoFactorDescription": "Denne organisasjonen krever to-faktor-autentisering.", "enableTwoFactor": "Aktiver to-faktor autentisering", "organizationSecurityPolicy": "Retningslinjer for organisasjons sikkerhet", "organizationSecurityPolicyDescription": "Denne organisasjonen har sikkerhetskrav som må oppfylles før du får tilgang til den", "securityRequirements": "Krav Til Sikkerhet", "allRequirementsMet": "Alle krav er oppfylt", "completeRequirementsToContinue": "Fullfør kravene nedenfor for å fortsette tilgangen til denne organisasjonen", "youCanNowAccessOrganization": "Du har nå tilgang til denne organisasjonen", "reauthenticationRequired": "Økt lengde", "reauthenticationDescription": "Denne organisasjonen krever at du logger på alle {maxDays} dager.", "reauthenticationDescriptionHours": "Denne organisasjonen krever at du logger inn hver {maxHours} time.", "reauthenticateNow": "Logg inn igjen", "adminEnabled2FaOnYourAccount": "Din administrator har aktivert tofaktorautentisering for {email}. Vennligst fullfør oppsettsprosessen for å fortsette.", "securityKeyAdd": "Legg til sikkerhetsnøkkel", "securityKeyRegisterTitle": "Registrer ny sikkerhetsnøkkel", "securityKeyRegisterDescription": "Koble til sikkerhetsnøkkelen og skriv inn et navn for å identifisere den", "securityKeyTwoFactorRequired": "Tofaktorautentisering påkrevd", "securityKeyTwoFactorDescription": "Vennligst skriv inn koden for tofaktorautentisering for å registrere sikkerhetsnøkkelen", "securityKeyTwoFactorRemoveDescription": "Vennligst skriv inn koden for tofaktorautentisering for å fjerne sikkerhetsnøkkelen", "securityKeyTwoFactorCode": "Tofaktorkode", "securityKeyRemoveTitle": "Fjern sikkerhetsnøkkel", "securityKeyRemoveDescription": "Skriv inn passordet ditt for å fjerne sikkerhetsnøkkelen \"{name}\"", "securityKeyNoKeysRegistered": "Ingen sikkerhetsnøkler registrert", "securityKeyNoKeysDescription": "Legg til en sikkerhetsnøkkel for å øke sikkerheten på kontoen din", "createDomainRequired": "Domene er påkrevd", "createDomainAddDnsRecords": "Legg til DNS-oppføringer", "createDomainAddDnsRecordsDescription": "Legg til følgende DNS-oppføringer hos din domeneleverandør for å fullføre oppsettet.", "createDomainNsRecords": "NS-oppføringer", "createDomainRecord": "Oppføring", "createDomainType": "Type:", "createDomainName": "Navn:", "createDomainValue": "Verdi:", "createDomainCnameRecords": "CNAME-oppføringer", "createDomainARecords": "A-oppføringer", "createDomainRecordNumber": "Oppføring {number}", "createDomainTxtRecords": "TXT-oppføringer", "createDomainSaveTheseRecords": "Lagre disse oppføringene", "createDomainSaveTheseRecordsDescription": "Sørg for å lagre disse DNS-oppføringene, da du ikke vil se dem igjen.", "createDomainDnsPropagation": "DNS-propagering", "createDomainDnsPropagationDescription": "DNS-endringer kan ta litt tid å propagere over internett. Dette kan ta fra noen få minutter til 48 timer, avhengig av din DNS-leverandør og TTL-innstillinger.", "resourcePortRequired": "Portnummer er påkrevd for ikke-HTTP-ressurser", "resourcePortNotAllowed": "Portnummer skal ikke angis for HTTP-ressurser", "billingPricingCalculatorLink": "Pris Kalkulator", "billingYourPlan": "Din funksjonsplan", "billingViewOrModifyPlan": "Vis eller endre gjeldende abonnement", "billingViewPlanDetails": "Se Planleggings detaljer", "billingUsageAndLimits": "Bruk og grenser", "billingViewUsageAndLimits": "Se planets grenser og gjeldende bruk", "billingCurrentUsage": "Gjeldende bruk", "billingMaximumLimits": "Maks antall grenser", "billingRemoteNodes": "Eksterne Noder", "billingUnlimited": "Ubegrenset", "billingPaidLicenseKeys": "Betalt lisensnøkler", "billingManageLicenseSubscription": "Administrer abonnementet for betalte lisensnøkler selv hostet", "billingCurrentKeys": "Nåværende nøkler", "billingModifyCurrentPlan": "Endre gjeldende plan", "billingConfirmUpgrade": "Bekreft oppgradering", "billingConfirmDowngrade": "Bekreft nedgradering", "billingConfirmUpgradeDescription": "Du er i ferd med å oppgradere abonnementet ditt. Gå gjennom de nye grensene og pris nedenfor.", "billingConfirmDowngradeDescription": "Du er i ferd med å nedgradere planen din. Gå gjennom de nye grensene og pris nedenfor.", "billingPlanIncludes": "Plan Inkluderer", "billingProcessing": "Behandler...", "billingConfirmUpgradeButton": "Bekreft oppgradering", "billingConfirmDowngradeButton": "Bekreft nedgradering", "billingLimitViolationWarning": "Bruk overbelastede grenser for ny plan", "billingLimitViolationDescription": "Gjeldende bruk overskrider grensene for denne planen. Etter nedgradering vil alle handlinger deaktiveres inntil du reduserer bruken innenfor de nye grensene. Vennligst se igjennom funksjonene under som er i øyeblikket over grensene. Begrensninger i vold:", "billingFeatureLossWarning": "Fremhev tilgjengelig varsel", "billingFeatureLossDescription": "Ved å nedgradere vil funksjoner som ikke er tilgjengelige i den nye planen automatisk bli deaktivert. Noen innstillinger og konfigurasjoner kan gå tapt. Vennligst gjennomgå prismatrisen for å forstå hvilke funksjoner som ikke lenger vil være tilgjengelige.", "billingUsageExceedsLimit": "Gjeldende bruk ({current}) overskrider grensen ({limit})", "billingPastDueTitle": "Betalingen har forfalt", "billingPastDueDescription": "Betalingen er forfalt. Vennligst oppdater betalingsmetoden din for å fortsette å bruke den gjeldende funksjonsplanen din. Hvis du ikke har løst deg, vil abonnementet ditt avbrytes, og du vil bli tilbakestilt til gratistiden.", "billingUnpaidTitle": "Abonnement ubetalt", "billingUnpaidDescription": "Ditt abonnement er ubetalt og du har blitt tilbakestilt til gratis kasse. Vennligst oppdater din betalingsmetode for å gjenopprette abonnementet.", "billingIncompleteTitle": "Betaling ufullstendig", "billingIncompleteDescription": "Betalingen er ufullstendig. Vennligst fullfør betalingsprosessen for å aktivere abonnementet.", "billingIncompleteExpiredTitle": "Betaling utløpt", "billingIncompleteExpiredDescription": "Din betaling ble aldri fullført, og har utløpt. Du har blitt tilbakestilt til gratis dekk. Vennligst abonner på nytt for å gjenopprette tilgangen til betalte funksjoner.", "billingManageSubscription": "Administrere ditt abonnement", "billingResolvePaymentIssue": "Vennligst løs ditt betalingsproblem før du oppgraderer eller nedgraderer betalingen", "signUpTerms": { "IAgreeToThe": "Jeg godtar", "termsOfService": "brukervilkårene", "and": "og", "privacyPolicy": "retningslinjer for personvern" }, "signUpMarketing": { "keepMeInTheLoop": "Hold meg i løken med nyheter, oppdateringer og nye funksjoner via e-post." }, "siteRequired": "Område er påkrevd.", "olmTunnel": "Olm-tunnel", "olmTunnelDescription": "Bruk Olm for klienttilkobling", "errorCreatingClient": "Feil ved oppretting av klient", "clientDefaultsNotFound": "Klientstandarder ikke funnet", "createClient": "Opprett klient", "createClientDescription": "Opprette en ny klient for å få tilgang til private ressurser", "seeAllClients": "Se alle klienter", "clientInformation": "Klientinformasjon", "clientNamePlaceholder": "Klientnavn", "address": "Adresse", "subnetPlaceholder": "Subnett", "addressDescription": "Den interne adressen til klienten. Må falle innenfor organisasjonens undernett.", "selectSites": "Velg områder", "sitesDescription": "Klienten vil ha tilkobling til de valgte områdene", "clientInstallOlm": "Installer Olm", "clientInstallOlmDescription": "Få Olm til å kjøre på systemet ditt", "clientOlmCredentials": "Legitimasjon", "clientOlmCredentialsDescription": "Dette er hvordan klienten vil godkjenne med serveren", "olmEndpoint": "Endpoint", "olmId": "ID", "olmSecretKey": "Sikkerhetsnøkkel", "clientCredentialsSave": "Lagre brukeropplysninger", "clientCredentialsSaveDescription": "Du vil bare kunne se dette én gang. Sørg for å kopiere det til et sikkert sted.", "generalSettingsDescription": "Konfigurer de generelle innstillingene for denne klienten", "clientUpdated": "Klient oppdatert", "clientUpdatedDescription": "Klienten er blitt oppdatert.", "clientUpdateFailed": "Klarte ikke å oppdatere klient", "clientUpdateError": "En feil oppstod under oppdatering av klienten.", "sitesFetchFailed": "Klarte ikke å hente områder", "sitesFetchError": "En feil oppstod under henting av områder.", "olmErrorFetchReleases": "En feil oppstod under henting av Olm-utgivelser.", "olmErrorFetchLatest": "En feil oppstod under henting av den nyeste Olm-utgivelsen.", "enterCidrRange": "Skriv inn CIDR-område", "resourceEnableProxy": "Aktiver offentlig proxy", "resourceEnableProxyDescription": "Aktiver offentlig proxying til denne ressursen. Dette gir tilgang til ressursen fra utsiden av nettverket gjennom skyen på en åpen port. Krever Traefik-konfigurasjon.", "externalProxyEnabled": "Ekstern proxy aktivert", "addNewTarget": "Legg til nytt mål", "targetsList": "Liste over mål", "advancedMode": "Avansert modus", "advancedSettings": "Avanserte innstillinger", "targetErrorDuplicateTargetFound": "Duplikat av mål funnet", "healthCheckHealthy": "Sunn", "healthCheckUnhealthy": "Usunn", "healthCheckUnknown": "Ukjent", "healthCheck": "Helsekontroll", "configureHealthCheck": "Konfigurer Helsekontroll", "configureHealthCheckDescription": "Sett opp helsekontroll for {target}", "enableHealthChecks": "Aktiver Helsekontroller", "enableHealthChecksDescription": "Overvåk helsen til dette målet. Du kan overvåke et annet endepunkt enn målet hvis nødvendig.", "healthScheme": "Metode", "healthSelectScheme": "Velg metode", "healthCheckPortInvalid": "Helsekontrollporten må være mellom 1 og 65535", "healthCheckPath": "Sti", "healthHostname": "IP / Vert", "healthPort": "Port", "healthCheckPathDescription": "Stien for å sjekke helsestatus.", "healthyIntervalSeconds": "Sunn intervall (sek)", "unhealthyIntervalSeconds": "Usunt intervall (sek)", "IntervalSeconds": "Sunt intervall", "timeoutSeconds": "Tidsavbrudd (sek)", "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": "Kommaseparert liste med kommandoer brukeren kan kjøre med sudo.", "sshCreateHomeDir": "Opprett hjemmappe", "sshUnixGroups": "Unix grupper", "sshUnixGroupsDescription": "Kommaseparerte Unix grupper for å legge brukeren til på mål-verten.", "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.", "customHeaders": "Egendefinerte topptekster", "customHeadersDescription": "Overskrifter som er adskilt med linje: Overskriftsnavn: verdi", "headersValidationError": "Topptekst må være i formatet: header-navn: verdi.", "saveHealthCheck": "Lagre Helsekontroll", "healthCheckSaved": "Helsekontroll Lagret", "healthCheckSavedDescription": "Helsekontrollkonfigurasjonen ble lagret", "healthCheckError": "Helsekontrollfeil", "healthCheckErrorDescription": "Det oppstod en feil under lagring av helsekontrollkonfigurasjonen", "healthCheckPathRequired": "Helsekontrollsti er påkrevd", "healthCheckMethodRequired": "HTTP-metode er påkrevd", "healthCheckIntervalMin": "Sjekkeintervallet må være minst 5 sekunder", "healthCheckTimeoutMin": "Timeout må være minst 1 sekund", "healthCheckRetryMin": "Forsøk på nytt må være minst 1", "httpMethod": "HTTP-metode", "selectHttpMethod": "Velg HTTP-metode", "domainPickerSubdomainLabel": "Underdomene", "domainPickerBaseDomainLabel": "Grunndomene", "domainPickerSearchDomains": "Søk i domener...", "domainPickerNoDomainsFound": "Ingen domener funnet", "domainPickerLoadingDomains": "Laster inn domener...", "domainPickerSelectBaseDomain": "Velg grunndomene...", "domainPickerNotAvailableForCname": "Ikke tilgjengelig for CNAME-domener", "domainPickerEnterSubdomainOrLeaveBlank": "Skriv inn underdomene eller la feltet stå tomt for å bruke grunndomene.", "domainPickerEnterSubdomainToSearch": "Skriv inn et underdomene for å søke og velge blant tilgjengelige gratis domener.", "domainPickerFreeDomains": "Gratis domener", "domainPickerSearchForAvailableDomains": "Søk etter tilgjengelige domener", "domainPickerNotWorkSelfHosted": "Merk: Gratis tilbudte domener er ikke tilgjengelig for selv-hostede instanser akkurat nå.", "resourceDomain": "Domene", "resourceEditDomain": "Rediger domene", "siteName": "Områdenavn", "proxyPort": "Port", "resourcesTableProxyResources": "Offentlig", "resourcesTableClientResources": "Privat", "resourcesTableNoProxyResourcesFound": "Ingen proxy-ressurser funnet.", "resourcesTableNoInternalResourcesFound": "Ingen interne ressurser funnet.", "resourcesTableDestination": "Destinasjon", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Alias adresse", "resourcesTableAliasAddressInfo": "Denne adressen er en del av organisasjonens undernettverk. Den brukes til å løse aliasposter ved hjelp av intern DNS-oppløsning.", "resourcesTableClients": "Klienter", "resourcesTableAndOnlyAccessibleInternally": "og er kun tilgjengelig internt når de er koblet til med en klient.", "resourcesTableNoTargets": "Ingen mål", "resourcesTableHealthy": "Frisk", "resourcesTableDegraded": "Nedgradert", "resourcesTableOffline": "Frakoblet", "resourcesTableUnknown": "Ukjent", "resourcesTableNotMonitored": "Ikke overvåket", "editInternalResourceDialogEditClientResource": "Rediger Private Ressurser", "editInternalResourceDialogUpdateResourceProperties": "Oppdater ressurskonfigurasjonen og få tilgangskontroller for {resourceName}", "editInternalResourceDialogResourceProperties": "Ressursegenskaper", "editInternalResourceDialogName": "Navn", "editInternalResourceDialogProtocol": "Protokoll", "editInternalResourceDialogSitePort": "Områdeport", "editInternalResourceDialogTargetConfiguration": "Målkonfigurasjon", "editInternalResourceDialogCancel": "Avbryt", "editInternalResourceDialogSaveResource": "Lagre ressurs", "editInternalResourceDialogSuccess": "Suksess", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Intern ressurs oppdatert vellykket", "editInternalResourceDialogError": "Feil", "editInternalResourceDialogFailedToUpdateInternalResource": "Mislyktes å oppdatere intern ressurs", "editInternalResourceDialogNameRequired": "Navn er påkrevd", "editInternalResourceDialogNameMaxLength": "Navn kan ikke være lengre enn 255 tegn", "editInternalResourceDialogProxyPortMin": "Proxy-port må være minst 1", "editInternalResourceDialogProxyPortMax": "Proxy-port må være mindre enn 65536", "editInternalResourceDialogInvalidIPAddressFormat": "Ugyldig IP-adresseformat", "editInternalResourceDialogDestinationPortMin": "Destinasjonsport må være minst 1", "editInternalResourceDialogDestinationPortMax": "Destinasjonsport må være mindre enn 65536", "editInternalResourceDialogPortModeRequired": "Protokoll, proxy-port og målport er påkrevd for portmodus", "editInternalResourceDialogMode": "Modus", "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Vert", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Destinasjon", "editInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.", "editInternalResourceDialogDestinationIPDescription": "IP eller vertsnavn til ressursen på nettstedets nettverk.", "editInternalResourceDialogDestinationCidrDescription": "CIDR-rekkevidden til ressursen på nettstedets nettverk.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Et valgfritt internt DNS-alias for denne ressursen.", "createInternalResourceDialogNoSitesAvailable": "Ingen tilgjengelige steder", "createInternalResourceDialogNoSitesAvailableDescription": "Du må ha minst ett Newt-område med et konfigureret delnett for å lage interne ressurser.", "createInternalResourceDialogClose": "Lukk", "createInternalResourceDialogCreateClientResource": "Opprett privat ressurs", "createInternalResourceDialogCreateClientResourceDescription": "Opprett en ny ressurs som bare vil være tilgjengelig for kunder som er koblet til organisasjonen", "createInternalResourceDialogResourceProperties": "Ressursegenskaper", "createInternalResourceDialogName": "Navn", "createInternalResourceDialogSite": "Område", "selectSite": "Velg område...", "noSitesFound": "Ingen områder funnet.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Områdeport", "createInternalResourceDialogSitePortDescription": "Bruk denne porten for å få tilgang til ressursen på området når du er tilkoblet med en klient.", "createInternalResourceDialogTargetConfiguration": "Målkonfigurasjon", "createInternalResourceDialogDestinationIPDescription": "IP eller vertsnavn til ressursen på nettstedets nettverk.", "createInternalResourceDialogDestinationPortDescription": "Porten på destinasjons-IP-en der ressursen kan nås.", "createInternalResourceDialogCancel": "Avbryt", "createInternalResourceDialogCreateResource": "Opprett ressurs", "createInternalResourceDialogSuccess": "Suksess", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Intern ressurs opprettet vellykket", "createInternalResourceDialogError": "Feil", "createInternalResourceDialogFailedToCreateInternalResource": "Kunne ikke opprette intern ressurs", "createInternalResourceDialogNameRequired": "Navn er påkrevd", "createInternalResourceDialogNameMaxLength": "Navn kan ikke være lengre enn 255 tegn", "createInternalResourceDialogPleaseSelectSite": "Vennligst velg et område", "createInternalResourceDialogProxyPortMin": "Proxy-port må være minst 1", "createInternalResourceDialogProxyPortMax": "Proxy-port må være mindre enn 65536", "createInternalResourceDialogInvalidIPAddressFormat": "Ugyldig IP-adresseformat", "createInternalResourceDialogDestinationPortMin": "Destinasjonsport må være minst 1", "createInternalResourceDialogDestinationPortMax": "Destinasjonsport må være mindre enn 65536", "createInternalResourceDialogPortModeRequired": "Protokoll, proxy-port og målport er påkrevd for portmodus", "createInternalResourceDialogMode": "Modus", "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Vert", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Destinasjon", "createInternalResourceDialogDestinationHostDescription": "IP-adressen eller vertsnavnet til ressursen på nettstedets nettverk.", "createInternalResourceDialogDestinationCidrDescription": "CIDR-rekkevidden til ressursen på nettstedets nettverk.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Et valgfritt internt DNS-alias for denne ressursen.", "siteConfiguration": "Konfigurasjon", "siteAcceptClientConnections": "Godta klientforbindelser", "siteAcceptClientConnectionsDescription": "Tillat brukere og klienter å få tilgang til ressurser på denne siden. Dette kan endres senere.", "siteAddress": "Nettstedsadresse (Avansert)", "siteAddressDescription": "Den interne adressen til nettstedet. Må falle innenfor organisasjonens undernett.", "siteNameDescription": "Visningsnavnet på nettstedet som kan endres senere.", "autoLoginExternalIdp": "Automatisk innlogging med ekstern IDP", "autoLoginExternalIdpDescription": "Omdiriger brukeren umiddelbart til den eksterne identitetsleverandøren for autentisering.", "selectIdp": "Velg IDP", "selectIdpPlaceholder": "Velg en IDP...", "selectIdpRequired": "Vennligst velg en IDP når automatisk innlogging er aktivert.", "autoLoginTitle": "Omdirigering", "autoLoginDescription": "Omdirigerer deg til den eksterne identitetsleverandøren for autentisering.", "autoLoginProcessing": "Forbereder autentisering...", "autoLoginRedirecting": "Omdirigerer til innlogging...", "autoLoginError": "Feil ved automatisk innlogging", "autoLoginErrorNoRedirectUrl": "Ingen omdirigerings-URL mottatt fra identitetsleverandøren.", "autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL.", "remoteExitNodeManageRemoteExitNodes": "Eksterne Noder", "remoteExitNodeDescription": "Egendrift din egen eksterne relé- og proxyservernode", "remoteExitNodes": "Noder", "searchRemoteExitNodes": "Søk noder...", "remoteExitNodeAdd": "Legg til Node", "remoteExitNodeErrorDelete": "Feil ved sletting av node", "remoteExitNodeQuestionRemove": "Er du sikker på at du vil fjerne noden fra organisasjonen?", "remoteExitNodeMessageRemove": "Når noden er fjernet, vil ikke lenger være tilgjengelig.", "remoteExitNodeConfirmDelete": "Bekreft sletting av Node", "remoteExitNodeDelete": "Slett Node", "sidebarRemoteExitNodes": "Eksterne Noder", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Sikkerhetsnøkkel", "remoteExitNodeCreate": { "title": "Opprett ekstern node", "description": "Opprett en ny egendrift ekstern relé- og proxyservernode", "viewAllButton": "Vis alle koder", "strategy": { "title": "Opprettelsesstrategi", "description": "Velg hvordan du vil opprette den eksterne noden", "adopt": { "title": "Adopter Node", "description": "Velg dette hvis du allerede har legitimasjon til noden." }, "generate": { "title": "Generer Nøkler", "description": "Velg denne hvis du vil generere nye nøkler for noden." } }, "adopt": { "title": "Adopter Eksisterende Node", "description": "Skriv inn opplysningene til den eksisterende noden du vil adoptere", "nodeIdLabel": "Node-ID", "nodeIdDescription": "ID-en til den eksisterende noden du vil adoptere", "secretLabel": "Sikkerhetsnøkkel", "secretDescription": "Den hemmelige nøkkelen til en eksisterende node", "submitButton": "Adopter Node" }, "generate": { "title": "Genererte Legitimasjoner", "description": "Bruk disse genererte opplysningene for å konfigurere noden", "nodeIdTitle": "Node-ID", "secretTitle": "Sikkerhet", "saveCredentialsTitle": "Legg til Legitimasjoner til Config", "saveCredentialsDescription": "Legg til disse legitimasjonene i din selv-hostede Pangolin node-konfigurasjonsfil for å fullføre koblingen.", "submitButton": "Opprett node" }, "validation": { "adoptRequired": "Node ID og Secret er påkrevd når du adopterer en eksisterende node" }, "errors": { "loadDefaultsFailed": "Feil ved lasting av standarder", "defaultsNotLoaded": "Standarder ikke lastet", "createFailed": "Kan ikke opprette node" }, "success": { "created": "Node opprettet" } }, "remoteExitNodeSelection": "Noden utvalg", "remoteExitNodeSelectionDescription": "Velg en node for å sende trafikk gjennom for dette lokale nettstedet", "remoteExitNodeRequired": "En node må velges for lokale nettsteder", "noRemoteExitNodesAvailable": "Ingen noder tilgjengelig", "noRemoteExitNodesAvailableDescription": "Ingen noder er tilgjengelige for denne organisasjonen. Opprett en node først for å bruke lokale nettsteder.", "exitNode": "Utgangsnode", "country": "Land", "rulesMatchCountry": "For tiden basert på kilde IP", "managedSelfHosted": { "title": "Administrert selv-hostet", "description": "Sikre og lavvedlikeholdsservere, selvbetjente Pangolin med ekstra klokker, og understell", "introTitle": "Administrert Self-Hosted Pangolin", "introDescription": "er et alternativ for bruk utviklet for personer som ønsker enkel og ekstra pålitelighet mens de fortsatt holder sine data privat og selvdrevne.", "introDetail": "Med dette valget kjører du fortsatt din egen Pangolin-node - tunneler, SSL-terminering og trafikken ligger på serveren din. Forskjellen er at behandling og overvåking håndteres gjennom vårt skydashbord, som låser opp en rekke fordeler:", "benefitSimplerOperations": { "title": "Enklere operasjoner", "description": "Ingen grunn til å kjøre din egen e-postserver eller sette opp kompleks varsling. Du vil få helsesjekk og nedetid varsler ut av boksen." }, "benefitAutomaticUpdates": { "title": "Automatiske oppdateringer", "description": "Cloud dashbordet utvikler seg raskt, så du får nye funksjoner og feilrettinger uten at du trenger å trekke nye beholdere manuelt hver gang." }, "benefitLessMaintenance": { "title": "Mindre vedlikehold", "description": "Ingen databasestyrer, sikkerhetskopier eller ekstra infrastruktur for å forvalte. Vi håndterer det i skyen." }, "benefitCloudFailover": { "title": "Sky feilslått", "description": "Hvis EK-gruppen din går ned, kan tunnlene midlertidig mislykkes i å nå våre sky-punkter til du tar den tilbake på nett." }, "benefitHighAvailability": { "title": "Høy tilgjengelighet (PoPs)", "description": "Du kan også legge ved flere noder til kontoen din for redundans og bedre ytelse." }, "benefitFutureEnhancements": { "title": "Fremtidige forbedringer", "description": "Vi planlegger å legge inn mer analyser, varsle og styringsverktøy for å gjøre din distribusjon enda mer robust." }, "docsAlert": { "text": "Lær mer om Managed Self-Hosted alternativet i vår", "documentation": "dokumentasjon" }, "convertButton": "Konverter denne noden til manuelt bruk" }, "internationaldomaindetected": "Internasjonalt domene oppdaget", "willbestoredas": "Vil bli lagret som:", "roleMappingDescription": "Bestem hvordan roller tilordnes brukere når innloggingen er aktivert når autog-rapportering er aktivert.", "selectRole": "Velg en rolle", "roleMappingExpression": "Uttrykk", "selectRolePlaceholder": "Velg en rolle", "selectRoleDescription": "Velg en rolle å tilordne alle brukere fra denne identitet leverandøren", "roleMappingExpressionDescription": "Skriv inn et JMESPath uttrykk for å hente rolleinformasjon fra ID-nøkkelen", "idpTenantIdRequired": "Bedriftens ID kreves", "invalidValue": "Ugyldig verdi", "idpTypeLabel": "Identitet leverandør type", "roleMappingExpressionPlaceholder": "F.eks. inneholder(grupper, 'admin') && 'Admin' ⋅'Medlem'", "idpGoogleConfiguration": "Google Konfigurasjon", "idpGoogleConfigurationDescription": "Konfigurer Google OAuth2 legitimasjonen", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Google OAuth2-klienten hemmelighet", "idpAzureConfiguration": "Azure Entra ID konfigurasjon", "idpAzureConfigurationDescription": "Konfigurer Azure Entra ID OAuth2 legitimasjon", "idpTenantId": "Leietaker-ID", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "Azure leant ID (funnet i Azure Active Directory-oversikten)", "idpAzureClientIdDescription": "Azure App registrerings klient-ID", "idpAzureClientSecretDescription": "Azure App Registrering Klient Hemmelig", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Google Konfigurasjon", "idpAzureConfigurationTitle": "Azure Entra ID konfigurasjon", "idpTenantIdLabel": "Leietaker-ID", "idpAzureClientIdDescription2": "Azure App registrerings klient-ID", "idpAzureClientSecretDescription2": "Azure App Registrering Klient Hemmelig", "idpGoogleDescription": "Google OAuth2/OIDC leverandør", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnett", "subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.", "customDomain": "Egendefinert domene", "authPage": "Autentiseringssider", "authPageDescription": "Sett et egendefinert domene for organisasjonens autentiseringssider", "authPageDomain": "Autentiseringsside domene", "authPageBranding": "Egendefinert merkevarebygging", "authPageBrandingDescription": "Konfigurer merkevarebyggingen som vises på autentiseringssidene for denne organisasjonen", "authPageBrandingUpdated": "Autentiseringsside-markedsføring oppdatert vellykket", "authPageBrandingRemoved": "Autentiseringsside-markedsføring fjernet vellykket", "authPageBrandingRemoveTitle": "Fjern markedsføring for autentiseringsside", "authPageBrandingQuestionRemove": "Er du sikker på at du vil fjerne merkevarebyggingen for autentiseringssider?", "authPageBrandingDeleteConfirm": "Bekreft sletting av merkevarebygging", "brandingLogoURL": "Logo URL", "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)", "brandingOrgTitle": "Tittel for organisasjonens autentiseringsside", "brandingOrgDescription": "{orgName} vil bli erstattet med organisasjonens navn", "brandingOrgSubtitle": "Undertittel for organisasjonens autentiseringsside", "brandingResourceTitle": "Tittel for ressursens autentiseringsside", "brandingResourceSubtitle": "Undertittel for ressursens autentiseringsside", "brandingResourceDescription": "{resourceName} vil bli erstattet med organisasjonens navn", "saveAuthPageDomain": "Lagre domene", "saveAuthPageBranding": "Lagre merkevarebygging", "removeAuthPageBranding": "Fjern merkevarebygging", "noDomainSet": "Ingen domene valgt", "changeDomain": "Endre domene", "selectDomain": "Velg domene", "restartCertificate": "Omstart sertifikat", "editAuthPageDomain": "Rediger auth sidedomene", "setAuthPageDomain": "Angi autoriseringsside domene", "failedToFetchCertificate": "Kunne ikke hente sertifikat", "failedToRestartCertificate": "Kan ikke starte sertifikat", "addDomainToEnableCustomAuthPages": "Brukere vil kunne få tilgang til organisasjonens innloggingsside og fullføre ressursautentisering ved å bruke dette domenet.", "selectDomainForOrgAuthPage": "Velg et domene for organisasjonens autentiseringsside", "domainPickerProvidedDomain": "Gitt domene", "domainPickerFreeProvidedDomain": "Gratis oppgitt domene", "domainPickerVerified": "Bekreftet", "domainPickerUnverified": "Uverifisert", "domainPickerInvalidSubdomainStructure": "Dette underdomenet inneholder ugyldige tegn eller struktur. Det vil automatisk bli utsatt når du lagrer.", "domainPickerError": "Feil", "domainPickerErrorLoadDomains": "Kan ikke laste organisasjonens domener", "domainPickerErrorCheckAvailability": "Kunne ikke kontrollere domenetilgjengelighet", "domainPickerInvalidSubdomain": "Ugyldig underdomene", "domainPickerInvalidSubdomainRemoved": "Inndata \"{sub}\" ble fjernet fordi det ikke er gyldig.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kunne ikke gjøres gyldig for {domain}.", "domainPickerSubdomainSanitized": "Underdomenet som ble sanivert", "domainPickerSubdomainCorrected": "\"{sub}\" var korrigert til \"{sanitized}\"", "orgAuthSignInTitle": "Organisasjonsinnlogging", "orgAuthChooseIdpDescription": "Velg din identitet leverandør for å fortsette", "orgAuthNoIdpConfigured": "Denne organisasjonen har ikke noen identitetstjeneste konfigurert. Du kan i stedet logge inn med Pangolin identiteten din.", "orgAuthSignInWithPangolin": "Logg inn med Pangolin", "orgAuthSignInToOrg": "Logg inn på en organisasjon", "orgAuthSelectOrgTitle": "Organisasjonsinnlogging", "orgAuthSelectOrgDescription": "Skriv inn organisasjons-ID-en din for å fortsette", "orgAuthOrgIdPlaceholder": "din-organisasjon", "orgAuthOrgIdHelp": "Skriv inn organisasjonens unike identifikator", "orgAuthSelectOrgHelp": "Etter å ha skrevet inn din organisasjons-ID, blir du videresendt til din organisasjons innloggingsside hvor du kan bruke SSO eller organisasjonens legitimasjon.", "orgAuthRememberOrgId": "Husk denne organisasjons-ID-en", "orgAuthBackToSignIn": "Tilbake til standard innlogging", "orgAuthNoAccount": "Har du ikke konto?", "subscriptionRequiredToUse": "Et abonnement er påkrevd for å bruke denne funksjonen.", "mustUpgradeToUse": "Du må oppgradere ditt abonnement for å bruke denne funksjonen.", "subscriptionRequiredTierToUse": "Denne funksjonen krever {tier} eller høyere.", "upgradeToTierToUse": "Oppgrader til {tier} eller høyere for å bruke denne funksjonen.", "subscriptionTierTier1": "Hjem", "subscriptionTierTier2": "Lag", "subscriptionTierTier3": "Forretninger", "subscriptionTierEnterprise": "Bedrift", "idpDisabled": "Identitetsleverandører er deaktivert.", "orgAuthPageDisabled": "Informasjons-siden for organisasjon er deaktivert.", "domainRestartedDescription": "Domene-verifiseringen ble startet på nytt", "resourceAddEntrypointsEditFile": "Rediger fil: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Rediger fil: docker-compose.yml", "emailVerificationRequired": "E-postbekreftelse er nødvendig. Logg inn på nytt via {dashboardUrl}/auth/login og fullfør dette trinnet. Kom deretter tilbake her.", "twoFactorSetupRequired": "To-faktor autentiseringsoppsett er nødvendig. Vennligst logg inn igjen via {dashboardUrl}/auth/login og fullfør dette steget. Kom deretter tilbake her.", "additionalSecurityRequired": "Ekstra sikkerhet kreves", "organizationRequiresAdditionalSteps": "Denne organisasjonen krever ytterligere sikkerhetstrinn før du får tilgang til ressurser.", "completeTheseSteps": "Fullfør disse trinnene", "enableTwoFactorAuthentication": "Aktiver to-faktor autentisering", "completeSecuritySteps": "Fullfør sikkerhetstrinnene", "securitySettings": "Sikkerhet innstillinger", "dangerSection": "Faresone", "dangerSectionDescription": "Slett permanent alle data tilknyttet denne organisasjonen", "securitySettingsDescription": "Konfigurer sikkerhetspolicyer for organisasjonen", "requireTwoFactorForAllUsers": "Krev to-faktor autentisering for alle brukere", "requireTwoFactorDescription": "Når aktivert må alle interne brukere i denne organisasjonen ha to-faktorautentisering aktivert for å få tilgang til organisasjonen.", "requireTwoFactorDisabledDescription": "Denne funksjonen krever en gyldig lisens (Enterprise) eller aktivt abonnement (SaaS)", "requireTwoFactorCannotEnableDescription": "Du må aktivere to-faktor-autentisering for din konto før det håndheves for alle brukere", "maxSessionLength": "Maksimal øktlengde", "maxSessionLengthDescription": "Angi maksimal varighet for brukerøkter. Etter denne gangen må brukerne logge inn på nytt.", "maxSessionLengthDisabledDescription": "Denne funksjonen krever en gyldig lisens (Enterprise) eller aktivt abonnement (SaaS)", "selectSessionLength": "Velg øktlengde", "unenforced": "Tvungen", "1Hour": "1 time", "3Hours": "3 timer", "6Hours": "6 timer", "12Hours": "12 timer", "1DaySession": "1 dag", "3Days": "3 dager", "7Days": "7 dager", "14Days": "14 dager", "30DaysSession": "30 dager", "90DaysSession": "90 dager", "180DaysSession": "180 dager", "passwordExpiryDays": "Passord utløper", "editPasswordExpiryDescription": "Angi antall dager før brukere må endre passordet sitt.", "selectPasswordExpiry": "Velg passordutløp", "30Days": "30 dager", "1Day": "1 dag", "60Days": "60 dager", "90Days": "90 dager", "180Days": "180 dager", "1Year": "1 år", "subscriptionBadge": "Abonnement kreves", "securityPolicyChangeWarning": "Sikkerhetsregler forandring advarsel", "securityPolicyChangeDescription": "Du er i ferd med å endre innstillingene for sikkerhetspolicy. Etter å ha spart må du kanskje gjenopplogge deg på for å oppfylle disse policyoppdateringene. Alle brukere som ikke samsvarer vil også måtte autentisere.", "securityPolicyChangeConfirmMessage": "Jeg bekrefter", "securityPolicyChangeWarningText": "Dette vil påvirke alle brukere i organisasjonen", "authPageErrorUpdateMessage": "Det oppstod en feil under oppdatering av innstillingene for godkjenningssiden", "authPageErrorUpdate": "Kunne ikke oppdatere autoriseringssiden", "authPageDomainUpdated": "Autentiseringsside-domenet ble oppdatert vellykket", "healthCheckNotAvailable": "Lokal", "rewritePath": "Omskriv sti", "rewritePathDescription": "Valgfritt omskrive stien før videresending til målet.", "continueToApplication": "Fortsett til applikasjonen", "checkingInvite": "Sjekker invitasjon", "setResourceHeaderAuth": "setResourceHeaderAuth", "resourceHeaderAuthRemove": "Fjern topptekst Auth", "resourceHeaderAuthRemoveDescription": "Topplinje autentisering fjernet.", "resourceErrorHeaderAuthRemove": "Kunne ikke fjerne topptekst autentisering", "resourceErrorHeaderAuthRemoveDescription": "Kunne ikke fjerne topptekst autentisering for ressursen.", "resourceHeaderAuthProtectionEnabled": "Topplinje autentisering aktivert", "resourceHeaderAuthProtectionDisabled": "Topplinje autentisering deaktivert", "headerAuthRemove": "Fjern topptekst Auth", "headerAuthAdd": "Legg til topptekst godkjenning", "resourceErrorHeaderAuthSetup": "Kunne ikke sette topptekst autentisering", "resourceErrorHeaderAuthSetupDescription": "Kunne ikke sette topplinje autentisering for ressursen.", "resourceHeaderAuthSetup": "Header godkjenningssett var vellykket", "resourceHeaderAuthSetupDescription": "Topplinje autentisering har blitt lagret.", "resourceHeaderAuthSetupTitle": "Angi topptekst godkjenning", "resourceHeaderAuthSetupTitleDescription": "Angi grunnleggende auth legitimasjon (brukernavn og passord) for å beskytte denne ressursen med HTTP Header autentisering. Tilgang til det ved hjelp av formatet https://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Angi topptekst godkjenning", "actionSetResourceHeaderAuth": "Angi topptekst godkjenning", "enterpriseEdition": "Enterprise Edition", "unlicensed": "Ikke lisensiert", "beta": "beta", "manageUserDevices": "Bruker Enheter", "manageUserDevicesDescription": "Se og administrer enheter som brukere bruker for privat tilkobling til ressurser", "downloadClientBannerTitle": "Last ned Pangolin-klienten", "downloadClientBannerDescription": "Last ned Pangolin-klienten for systemet ditt for å koble til Pangolin-nettverket og få tilgang til ressurser privat.", "manageMachineClients": "Administrer maskinneklienter", "manageMachineClientsDescription": "Opprett og behandle klienter som servere og systemer bruker for privat tilkobling til ressurser", "machineClientsBannerTitle": "Servere og automatiserte systemer", "machineClientsBannerDescription": "Maskinklienter er for servere og automatiserte systemer som ikke er tilknyttet en spesifikk bruker. De autentiserer med en ID og et hemmelighetsnummer, og kan kjøre med Pangolin CLI, Olm CLI eller Olm som en container.", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Olm Container", "clientsTableUserClients": "Bruker", "clientsTableMachineClients": "Maskin", "licenseTableValidUntil": "Gyldig til", "saasLicenseKeysSettingsTitle": "Bedriftstillatelse Lisenser", "saasLicenseKeysSettingsDescription": "Generer og administrer Enterprise lisensnøkler for selvbetjente Pangolin forekomster", "sidebarEnterpriseLicenses": "Lisenser", "generateLicenseKey": "Generer lisensnøkkel", "generateLicenseKeyForm": { "validation": { "emailRequired": "Vennligst skriv inn en gyldig e-postadresse", "useCaseTypeRequired": "Vennligst velg en bruk sakstype", "firstNameRequired": "Fornavn er påkrevd", "lastNameRequired": "Etternavn er påkrevd", "primaryUseRequired": "Beskriv din primære bruk", "jobTitleRequiredBusiness": "Jobbtittel er påkrevd for forretningsbruk", "industryRequiredBusiness": "Næringslivet må til forretningsbruk", "stateProvinceRegionRequired": "Stat/provins/region kreves", "postalZipCodeRequired": "Postnummer er påkrevd", "companyNameRequiredBusiness": "Firmanavn er påkrevd for bedriftens bruk", "countryOfResidenceRequiredBusiness": "Land som skal oppholde seg er nødvendig for bruk til forretningsdrift", "countryRequiredPersonal": "Land er påkrevd for personlig bruk", "agreeToTermsRequired": "Du må godta vilkårene", "complianceConfirmationRequired": "Du må bekrefte at du overholder Fossorial Kommersiell lisens" }, "useCaseOptions": { "personal": { "title": "Personlig bruk", "description": "For enkeltpersoner, ikke-kommersiell bruk som læring, personlige prosjekter eller eksperimentering." }, "business": { "title": "Forretningsmessig bruk", "description": "Til bruk innenfor organisasjoner eller virksomheter eller forretningsmessige inntekter eller aktiviteter." } }, "steps": { "emailLicenseType": { "title": "E-post & lisenstype", "description": "Skriv inn e-postadressen din og velg lisenstypen din" }, "personalInformation": { "title": "Personlig Informasjon", "description": "Fortell oss om deg selv" }, "contactInformation": { "title": "Kontakt Informasjon", "description": "Dine kontaktopplysninger" }, "termsGenerate": { "title": "Vilkår og generere", "description": "Se gjennom og godta vilkårene for å generere lisensen" } }, "alerts": { "commercialUseDisclosure": { "title": "Bruk utlevering", "description": "Velg lisensnivået som reflekterer nøyaktig din tiltenkte bruk. Personlige lisenser tillater fri bruk av programvare for enkelte, ikke-kommersielle eller småskala kommersielle aktiviteter, med en årlig brutto inntekt på under 100 000 amerikanske dollar. All bruk ut over disse grensene – inkludert bruk innenfor en virksomhet, organisasjon eller andre inntekter miljø - krever en gyldig Enterprise lisens og betaling av gjeldende lisensavgift. Alle brukere, enten personlig eller Enterprise, må overholde de kommersielle tillatelsene på Fossorial." }, "trialPeriodInformation": { "title": "Informasjon om prøveperiode", "description": "Denne lisensnøkkelen tillater funksjoner i Enterprise for en 7-dagers evalueringsperiode. Fortsatt tilgang til betalt funksjoner utenfor evalueringsperioden krever aktivering under en gyldig Personlig eller Enterprise License. For Enterprise licensing, kontakt sales@pangolin.net." } }, "form": { "useCaseQuestion": "Bruker du Pangolin for personlig eller forretningsbruk?", "firstName": "Fornavn", "lastName": "Etternavn (Automatic Translation)", "jobTitle": "Jobb tittel", "primaryUseQuestion": "Hva planlegger du først og fremst å bruke Pangolin for?", "industryQuestion": "Hva er din industri?", "prospectiveUsersQuestion": "Hvor mange prospektive brukere forventer du å ha?", "prospectiveSitesQuestion": "Hvor mange prospektive nettsteder (tunnels) forventer du å ha?", "companyName": "Navn på bedrift", "countryOfResidence": "Oppholdsland", "stateProvinceRegion": "Fylke / Region", "postalZipCode": "Postnummer / postnummer", "companyWebsite": "Firmaets hjemmeside", "companyPhoneNumber": "Firmaets telefonnummer", "country": "Land", "phoneNumberOptional": "Telefonnummer (valgfritt)", "complianceConfirmation": "Jeg bekrefter at opplysningene jeg oppga er korrekte og at jeg er i samsvar med Fossorial Kommersiell Lisens. Rapportering av unøyaktig informasjon eller feilidentifisering av bruk av produktet bryter lisensen, og kan føre til at nøkkelen din blir opphevet." }, "buttons": { "close": "Lukk", "previous": "Forrige", "next": "Neste", "generateLicenseKey": "Generer lisensnøkkel" }, "toasts": { "success": { "title": "Lisensnøkkel ble generert", "description": "Din lisensnøkkel har blitt generert og er klar til bruk." }, "error": { "title": "Kan ikke generere lisensnøkkel", "description": "Det oppstod en feil ved generering av lisensnøkkelen." } } }, "newPricingLicenseForm": { "title": "Få en lisens", "description": "Velg en plan og fortell oss hvordan du planlegger å bruke Pangolin.", "chooseTier": "Velg din funksjonsplan", "viewPricingLink": "Se prising, egenskaper og grenser", "tiers": { "starter": { "title": "Begynner", "description": "Enterprise features, 25 brukere, 25 sitater og støtte fra fellesskapet." }, "scale": { "title": "Skala", "description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte." } }, "personalUseOnly": "Kun personlig bruk (gratis lisens - ingen utsjekking)", "buttons": { "continueToCheckout": "Fortsett til kassen" }, "toasts": { "checkoutError": { "title": "Feil ved utsjekk", "description": "Kan ikke starte kassen. Prøv på nytt." } } }, "priority": "Prioritet", "priorityDescription": "Høyere prioriterte ruter evalueres først. Prioritet = 100 betyr automatisk bestilling (systembeslutninger). Bruk et annet nummer til å håndheve manuell prioritet.", "instanceName": "Forekomst navn", "pathMatchModalTitle": "Konfigurere matching av sti", "pathMatchModalDescription": "Sett opp hvordan innkommende forespørsler skal matches basert på deres bane.", "pathMatchType": "Trefftype", "pathMatchPrefix": "Prefiks", "pathMatchExact": "Nøyaktig", "pathMatchRegex": "Regex", "pathMatchValue": "Verdi for sti", "clear": "Tøm", "saveChanges": "Lagre endringer", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/sti", "pathMatchPrefixHelp": "Eksempel: /api matcher /api, /api/users, etc.", "pathMatchExactHelp": "Eksempel: /api passer bare /api", "pathMatchRegexHelp": "Eksempel: ^/api/.* matcher /api/alt", "pathRewriteModalTitle": "Konfigurer ferdigskriving av sti", "pathRewriteModalDescription": "Overfør banen som passet før den videresendes til målet.", "pathRewriteType": "Omskriv type", "pathRewritePrefixOption": "Prefiks - erstatt prefiks", "pathRewriteExactOption": "Eksakt - Erstatt hele banen", "pathRewriteRegexOption": "Regex - Ekkobilde", "pathRewriteStripPrefixOption": "Ta bort prefiks - fjern prefiks", "pathRewriteValue": "Omskriv verdi", "pathRewriteRegexPlaceholder": "/ny/$1", "pathRewriteDefaultPlaceholder": "/ny sti", "pathRewritePrefixHelp": "Erstatt det samsvarende prefikset med denne verdien", "pathRewriteExactHelp": "Erstatt hele banen med denne verdien når stien samsvarer nøyaktig", "pathRewriteRegexHelp": "Bruk opptaksgrupper som $1, $2 for erstatning", "pathRewriteStripPrefixHelp": "La stå tomt for å ta opp prefiks eller legge til nytt prefiks", "pathRewritePrefix": "Prefiks", "pathRewriteExact": "Nøyaktig", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Stripe", "pathRewriteStripLabel": "stripe", "sidebarEnableEnterpriseLicense": "Aktiver Enterprise lisens", "cannotbeUndone": "Dette kan ikke angres.", "toConfirm": "å bekrefte.", "deleteClientQuestion": "Er du sikker på at du vil fjerne klienten fra nettstedet og organisasjonen?", "clientMessageRemove": "Når klienten er fjernet, kan den ikke lenger koble seg til nettstedet.", "sidebarLogs": "Logger", "request": "Forespørsel", "requests": "Forespørsler", "logs": "Logger", "logsSettingsDescription": "Overvåk logger samlet inn fra denne organisasjonen", "searchLogs": "Søk i logger...", "action": "Handling", "actor": "Aktør", "timestamp": "Tidsstempel", "accessLogs": "Tilgangslogger (Automatic Translation)", "exportCsv": "Eksportere CSV", "exportError": "Ukjent feil ved eksport av CSV", "exportCsvTooltip": "Innenfor tidsramme", "actorId": "Skuespiller ID", "allowedByRule": "Tillatt etter regel", "allowedNoAuth": "Tillatt Ingen Auth", "validAccessToken": "Gyldig tilgangsnøkkel", "validHeaderAuth": "Valid header auth", "validPincode": "Valid Pincode", "validPassword": "Gyldig passord", "validEmail": "Valid email", "validSSO": "Valid SSO", "resourceBlocked": "Ressurs blokkert", "droppedByRule": "Legg i regelen", "noSessions": "Ingen økter", "temporaryRequestToken": "Midlertidig forespørsel Token", "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Grunn", "requestLogs": "Forespørselslogger (Automatic Translation)", "requestAnalytics": "Be om analyser", "host": "Vert", "location": "Sted", "actionLogs": "Handlingslogger", "sidebarLogsRequest": "Forespørselslogger (Automatic Translation)", "sidebarLogsAccess": "Tilgangslogger (Automatic Translation)", "sidebarLogsAction": "Handlingslogger", "logRetention": "Logg tilbaketrekning", "logRetentionDescription": "Håndter hvor lenge ulike typer logger beholdes for denne organisasjonen, eller deaktiver dem", "requestLogsDescription": "Se detaljerte forespørselslogger for ressurser i denne organisasjonen", "requestAnalyticsDescription": "Se detaljert rekvisisjonsanalyse for ressurser i denne organisasjonen", "logRetentionRequestLabel": "Be om loggoverføring", "logRetentionRequestDescription": "Hvor lenge du vil beholde forespørselslogger", "logRetentionAccessLabel": "Få tilgang til loggoverføring", "logRetentionAccessDescription": "Hvor lenge du vil beholde adgangslogger", "logRetentionActionLabel": "Handlings logg nytt", "logRetentionActionDescription": "Hvor lenge handlingen skal lagres", "logRetentionDisabled": "Deaktivert", "logRetention3Days": "3 dager", "logRetention7Days": "7 dager", "logRetention14Days": "14 dager", "logRetention30Days": "30 dager", "logRetention90Days": "90 dager", "logRetentionForever": "Alltid", "logRetentionEndOfFollowingYear": "Slutt på neste år", "actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen", "accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen", "licenseRequiredToUse": "En Enterprise Edition lisens er påkrevd for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i Pangolin Cloud.", "ossEnterpriseEditionRequired": "Enterprise Edition er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i Pangolin Cloud.", "certResolver": "Sertifikat løser", "certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.", "selectCertResolver": "Velg sertifikatløser", "enterCustomResolver": "Legg inn egendefinert løser", "preferWildcardCert": "Foretrekk Wildcard sertifikat", "unverified": "Uverifisert", "domainSetting": "Domene innstillinger", "domainSettingDescription": "Konfigurer innstillinger for domenet", "preferWildcardCertDescription": "Forsøk å generere et jokertegn-sertifikat (krever en riktig konfigurert sertifikatløser).", "recordName": "Lagre navn", "auto": "Automatisk", "TTL": "TTL", "howToAddRecords": "Hvordan legge til poster", "dnsRecord": "DNS registre", "required": "Påkrevd", "domainSettingsUpdated": "Domene innstillinger ble oppdatert", "orgOrDomainIdMissing": "ID for organisasjon eller domene mangler", "loadingDNSRecords": "Laster DNS-poster...", "olmUpdateAvailableInfo": "En oppdatert versjon av Olm er tilgjengelig. Oppdater til den nyeste versjonen for å få den beste opplevelsen.", "client": "Klient", "proxyProtocol": "Protokoll innstillinger for Protokoll", "proxyProtocolDescription": "Konfigurer Proxy-protokoll for å bevare klientens IP-adresser til TCP-tjenester.", "enableProxyProtocol": "Aktiver Proxy-protokoll", "proxyProtocolInfo": "Bevar klientens IP-adresser for TCP backends", "proxyProtocolVersion": "Proxy protokoll versjon", "version1": " Versjon 1 (Anbefalt)", "version2": "Versjon 2", "versionDescription": "Versjon 1 er tekstbasert og støttet. Versjon 2 er binært og mer effektivt, men mindre kompatibel.", "warning": "Advarsel", "proxyProtocolWarning": "backend-programmet må konfigureres til å akseptere forbindelser i Proxy Protokoll. Hvis backend ikke støtter Proxy Beskyttelse vil aktivering av dette ødelegge alle tilkoblinger så bare dette hvis du vet hva du gjør. Sørg for å konfigurere backend til å stole på Proxy Protokoll overskrifter fra Traefik.", "restarting": "Restarter...", "manual": "Manuell", "messageSupport": "Støtte for melding", "supportNotAvailableTitle": "Støtte ikke tilgjengelig", "supportNotAvailableDescription": "Støtte er ikke tilgjengelig akkurat nå. Du kan sende en e-post til support@pangolin.net.", "supportRequestSentTitle": "Supportforespørsel sendt", "supportRequestSentDescription": "Din melding er sendt.", "supportRequestFailedTitle": "Kunne ikke sende forespørsel", "supportRequestFailedDescription": "En feil oppstod under sending av din forespørsel om støtte.", "supportSubjectRequired": "Emne er påkrevd", "supportSubjectMaxLength": "Emne må være 255 tegn eller mindre", "supportMessageRequired": "Melding er påkrevd", "supportReplyTo": "Svar til", "supportSubject": "Emne", "supportSubjectPlaceholder": "Angi emne", "supportMessage": "Melding", "supportMessagePlaceholder": "Skriv din melding", "supportSending": "Sender...", "supportSend": "Sende", "supportMessageSent": "Melding sendt!", "supportWillContact": "Vi kommer raskt til å ta kontakt!", "selectLogRetention": "Velg oppbevaring av logg", "terms": "Vilkår", "privacy": "Personvern", "security": "Sikkerhet", "docs": "Dokumenter", "deviceActivation": "Aktivering av enhet", "deviceCodeInvalidFormat": "Kode må inneholde 9 tegn (f.eks A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Ugyldig eller utløpt kode", "deviceCodeVerifyFailed": "Klarte ikke å bekrefte enhetskoden", "deviceCodeValidating": "Validerer enhetskode...", "deviceCodeVerifying": "Bekrefter enhetens godkjennelse...", "signedInAs": "Logget inn som", "deviceCodeEnterPrompt": "Skriv inn koden som vises på enheten", "continue": "Fortsett", "deviceUnknownLocation": "Ukjent plassering", "deviceAuthorizationRequested": "Denne autorisasjonen ble forespurt fra {location} på {date}. Pass på at du stoler på denne enheten siden den vil få tilgang til kontoen.", "deviceLabel": "Enhet: {deviceName}", "deviceWantsAccess": "ønsker å få tilgang til kontoen din", "deviceExistingAccess": "Eksisterende tilgang:", "deviceFullAccess": "Full tilgang til din konto", "deviceOrganizationsAccess": "Tilgang til alle organisasjoner din konto har tilgang til", "deviceAuthorize": "Autoriser {applicationName}", "deviceConnected": "Enhet tilkoblet!", "deviceAuthorizedMessage": "Enheten er autorisert for tilgang til kontoen. Vennligst gå tilbake til klientapplikasjonen.", "pangolinCloud": "Pangolin Sky", "viewDevices": "Vis enheter", "viewDevicesDescription": "Administrer tilkoblede enheter", "noDevices": "Ingen enheter funnet", "dateCreated": "Opprettet dato", "unnamedDevice": "Navnløs enhet", "deviceQuestionRemove": "Er du sikker på at du vil slette denne enheten?", "deviceMessageRemove": "Denne handlingen kan ikke angres.", "deviceDeleteConfirm": "Slett enhet", "deleteDevice": "Slett enhet", "errorLoadingDevices": "Feil ved lasting av enheter", "failedToLoadDevices": "Klarte ikke å laste enheter", "deviceDeleted": "Enheten er slettet", "deviceDeletedDescription": "Enheten har blitt slettet.", "errorDeletingDevice": "Feil ved sletting av enhet", "failedToDeleteDevice": "Kunne ikke slette enheten", "showColumns": "Vis kolonner", "hideColumns": "Skjul kolonner", "columnVisibility": "Kolonne Synlighet", "toggleColumn": "Veksle {columnName} kolonne", "allColumns": "Alle kolonner", "defaultColumns": "Standard kolonner", "customizeView": "Tilpass visning", "viewOptions": "Vis alternativer", "selectAll": "Velg alle", "selectNone": "Velg ingen", "selectedResources": "Valgte ressurser", "enableSelected": "Aktiver valgte", "disableSelected": "Deaktiver valgte", "checkSelectedStatus": "Kontroller status for valgte", "clients": "Klienter", "accessClientSelect": "Velg maskinklienter", "resourceClientDescription": "Maskinklienter som har tilgang til denne ressursen", "regenerate": "Regenerer", "credentials": "Legitimasjon", "savecredentials": "Lagre brukeropplysninger", "regenerateCredentialsButton": "Regenerer brukeropplysninger", "regenerateCredentials": "Regenerer brukeropplysninger", "generatedcredentials": "Genererte brukeropplysninger", "copyandsavethesecredentials": "Kopier og lagre disse opplysningene", "copyandsavethesecredentialsdescription": "Disse opplysningene vil ikke bli vist igjen etter at du forlater siden. Lagre dem trygt nå.", "credentialsSaved": "Påloggingsinformasjon lagret", "credentialsSavedDescription": "Påloggingsinformasjonen har blitt regenerert og lagret.", "credentialsSaveError": "Påloggingsinformasjon lagre feil", "credentialsSaveErrorDescription": "En feil oppstod under regenerering og lagring av legitimasjon.", "regenerateCredentialsWarning": "Regenerering av legitimasjon vil ugyldiggjøre de forrige og forårsake en frakobling. Sørg for å oppdatere alle konfigurasjoner som bruker disse legitimasjonene.", "confirm": "Bekreft", "regenerateCredentialsConfirmation": "Er du sikker på at du vil regenerere legetimasjonene?", "endpoint": "Endpoint", "Id": "Id", "SecretKey": "Hemmelig nøkkel", "niceId": "God ID", "niceIdUpdated": "Flott ID oppdatert", "niceIdUpdatedSuccessfully": "Id-en ble oppdatert", "niceIdUpdateError": "Feil under oppdatering av hyggelig ID", "niceIdUpdateErrorDescription": "Det oppstod en feil under oppdatering av Nice ID.", "niceIdCannotBeEmpty": "God ID kan ikke være tom", "enterIdentifier": "Angi identifikator", "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Ikke du? Bruk en annen konto.", "deviceLoginDeviceRequestingAccessToAccount": "En enhet ber om tilgang til denne kontoen.", "loginSelectAuthenticationMethod": "Velg en autentiseringsmetode for å fortsette.", "noData": "Ingen data", "machineClients": "Maskinklienter", "install": "Installer", "run": "Kjør", "clientNameDescription": "Visningsnavnet til klienten som kan endres senere.", "clientAddress": "Klientadresse (avansert)", "setupFailedToFetchSubnet": "Kunne ikke hente standard undernett", "setupSubnetAdvanced": "Subnet (avansert)", "setupSubnetDescription": "Subnet for denne organisasjonens interne nettverk.", "setupUtilitySubnet": "Utility Subnet (Avansert)", "setupUtilitySubnetDescription": "Subnettet for denne organisasjonens aliasadresser og DNS-server.", "siteRegenerateAndDisconnect": "Regenerer og koble fra", "siteRegenerateAndDisconnectConfirmation": "Er du sikker på at du vil regenerere legitimasjon og koble fra dette nettstedet?", "siteRegenerateAndDisconnectWarning": "Dette vil regenerere legitimasjon og umiddelbart koble fra siden. Siden må startes på nytt med de nye legitimasjonene.", "siteRegenerateCredentialsConfirmation": "Er du sikker på at du vil regenerere legitimasjon for dette nettstedet?", "siteRegenerateCredentialsWarning": "Dette vil regenerere legitimasjonen. Siden vil forbli tilkoblet inntil du manuelt starter den på nytt og bruker de nye legitimasjonen.", "clientRegenerateAndDisconnect": "Regenerer og koble fra", "clientRegenerateAndDisconnectConfirmation": "Er du sikker på at du vil regenerere legitimasjon og koble fra denne klienten?", "clientRegenerateAndDisconnectWarning": "Dette vil regenerere legitimasjon og umiddelbart koble fra klienten. Kunden må startes på nytt med de nye legitimasjonene.", "clientRegenerateCredentialsConfirmation": "Er du sikker på at du vil regenerere legitimasjon for denne klienten?", "clientRegenerateCredentialsWarning": "Dette vil regenerere legitimasjon. Klienten vil forbli tilkoblet inntil du manuelt starter den på nytt og bruker de nye legitimasjonen.", "remoteExitNodeRegenerateAndDisconnect": "Regenerer og koble fra", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Er du sikker på at du vil regenerere legitimasjon og koble fra denne eksterne avslutnings noden?", "remoteExitNodeRegenerateAndDisconnectWarning": "Dette vil regenerere innloggingsdetaljene og umiddelbart koble fra den eksterne avkjøringnoden. Ekstern avkjøringsnode må startes på nytt med de nye opplysningene", "remoteExitNodeRegenerateCredentialsConfirmation": "Er du sikker på at du vil regenerere innloggingsene for denne eksterne avslutningen?", "remoteExitNodeRegenerateCredentialsWarning": "Dette vil regenerere legitimasjon. Ekstern avgang noden vil forbli tilkoblet inntil du manuelt gjenoppstarter den og bruker de nye legitimasjonene.", "agent": "Agent", "personalUseOnly": "Kun til personlig bruk", "loginPageLicenseWatermark": "Denne instansen er lisensiert kun for personlig bruk.", "instanceIsUnlicensed": "Denne instansen er ulisensiert.", "portRestrictions": "Portbegrensninger", "allPorts": "Alle", "custom": "Egendefinert", "allPortsAllowed": "Alle porter tillatt", "allPortsBlocked": "Alle porter blokkert", "tcpPortsDescription": "Spesifiser hvilke TCP-porter som er tillatt for denne ressursen. Bruk '*' for alle porter, la stå tomt for å blokkere alle, eller skriv inn en kommaseparert liste over porter og sjikt (f.eks. 80,443,8000-9000).", "udpPortsDescription": "Spesifiser hvilke UDP-porter som er tillatt for denne ressursen. Bruk '*' for alle porter, la stå tomt for å blokkere alle, eller skriv inn en kommaseparert liste over porter og sjikt (f.eks. 53,123,500-600).", "organizationLoginPageTitle": "Organisasjonens innloggingsside", "organizationLoginPageDescription": "Tilpass innloggingssiden for denne organisasjonen", "resourceLoginPageTitle": "Ressursens innloggingsside", "resourceLoginPageDescription": "Tilpass innloggingssiden for individuelle ressurser", "enterConfirmation": "Skriv inn bekreftelse", "blueprintViewDetails": "Detaljer", "defaultIdentityProvider": "Standard identitetsleverandør", "defaultIdentityProviderDescription": "Når en standard identitetsleverandør er valgt, vil brukeren automatisk bli omdirigert til leverandøren for autentisering.", "editInternalResourceDialogNetworkSettings": "Nettverksinnstillinger", "editInternalResourceDialogAccessPolicy": "Tilgangsregler for tilgang", "editInternalResourceDialogAddRoles": "Legg til roller", "editInternalResourceDialogAddUsers": "Legg til brukere", "editInternalResourceDialogAddClients": "Legg til klienter", "editInternalResourceDialogDestinationLabel": "Destinasjon", "editInternalResourceDialogDestinationDescription": "Spesifiser destinasjonsadressen for den interne ressursen. Dette kan være et vertsnavn, IP-adresse eller CIDR-sjikt avhengig av valgt modus. Valgfrie oppsett av intern DNS-alias for enklere identifikasjon.", "editInternalResourceDialogPortRestrictionsDescription": "Begrens tilgang til spesifikke TCP/UDP-porter eller tillate/blokkere alle porter.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Tilgangskontroll", "editInternalResourceDialogAccessControlDescription": "Kontroller hvilke roller, brukere og maskinklienter som har tilgang til denne ressursen når den er koblet til. Administratorer har alltid tilgang.", "editInternalResourceDialogPortRangeValidationError": "Portsjiktet må være \"*\" for alle porter, eller en kommaseparert liste med porter og sjikt (f.eks. \"80,443,8000-9000\"). Porter må være mellom 1 og 65535.", "internalResourceAuthDaemonStrategy": "SSH Auth Daemon Sted", "internalResourceAuthDaemonStrategyDescription": "Velg hvor SSH-autentisering daemon kjører: på nettstedet (Newt) eller på en ekstern vert.", "internalResourceAuthDaemonDescription": "SSH-godkjenning daemon håndterer SSH-nøkkel signering og PAM autentisering for denne ressursen. Velg om den kjører på nettstedet (Newt) eller på en separat ekstern vert. Se dokumentasjonen for mer.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Velg strategi", "internalResourceAuthDaemonStrategyLabel": "Sted", "internalResourceAuthDaemonSite": "På nettsted", "internalResourceAuthDaemonSiteDescription": "Autentiser daemon kjører på nettstedet (Newt).", "internalResourceAuthDaemonRemote": "Ekstern vert", "internalResourceAuthDaemonRemoteDescription": "Autentiser daemon kjører på en vert som ikke er nettstedet.", "internalResourceAuthDaemonPort": "Daemon Port (valgfritt)", "orgAuthWhatsThis": "Hvor kan jeg finne min organisasjons-ID?", "learnMore": "Lær mer", "backToHome": "Gå tilbake til start", "needToSignInToOrg": "Trenger du å bruke organisasjonens identitetsleverandør?", "maintenanceMode": "Vedlikeholdsmodus", "maintenanceModeDescription": "Vis en vedlikeholdsside til besøkende", "maintenanceModeType": "Vedlikeholdsmodus type", "showMaintenancePage": "Vis en vedlikeholdsside til besøkende", "enableMaintenanceMode": "Aktiver vedlikeholdsmodus", "automatic": "Automatisk", "automaticModeDescription": "Vis vedlikeholdsside kun når alle serverens mål er nede eller usunne. Ressursen din fortsetter å fungere normalt så lenge minst ett mål er sunt.", "forced": "Tvunget", "forcedModeDescription": "Vis alltid vedlikeholdssiden uavhengig av serverens helse. Bruk dette ved planlagt vedlikehold når du vil hindre all tilgang.", "warning:": "Advarsel:", "forcedeModeWarning": "All trafikk vil bli dirigeres til vedlikeholdssiden. Serverens ressurser vil ikke motta noen forespørsler.", "pageTitle": "Sidetittel", "pageTitleDescription": "Hovedoverskriften vist på vedlikeholdssiden", "maintenancePageMessage": "Vedlikeholdsbeskjed", "maintenancePageMessagePlaceholder": "Vi kommer snart tilbake! Vårt nettsted gjennomgår for øyeblikket planlagt vedlikehold.", "maintenancePageMessageDescription": "Detaljert beskjed som forklarer vedlikeholdet", "maintenancePageTimeTitle": "Estimert ferdigstillelsestid (Valgfritt)", "maintenanceTime": "f.eks. 2 timer, 1. november kl. 17:00", "maintenanceEstimatedTimeDescription": "Når du forventer at vedlikeholdet er ferdigstilt", "editDomain": "Rediger domene", "editDomainDescription": "Velg et domene for ressursen din", "maintenanceModeDisabledTooltip": "Denne funksjonen krever en gyldig lisens for å aktiveres.", "maintenanceScreenTitle": "Tjenesten er midlertidig utilgjengelig", "maintenanceScreenMessage": "Vi opplever for øyeblikket tekniske problemer. Vennligst sjekk igjen snart.", "maintenanceScreenEstimatedCompletion": "Estimert ferdigstillelse:", "createInternalResourceDialogDestinationRequired": "Destinasjonen er nødvendig", "available": "Tilgjengelig", "archived": "Arkivert", "noArchivedDevices": "Ingen arkiverte enheter funnet", "deviceArchived": "Enhet arkivert", "deviceArchivedDescription": "Enheten er blitt arkivert.", "errorArchivingDevice": "Feil ved arkivering av enhet", "failedToArchiveDevice": "Kunne ikke arkivere enhet", "deviceQuestionArchive": "Er du sikker på at du vil arkivere denne enheten?", "deviceMessageArchive": "Enheten blir arkivert og fjernet fra listen over aktive enheter.", "deviceArchiveConfirm": "Arkiver enhet", "archiveDevice": "Arkiver enhet", "archive": "Arkiv", "deviceUnarchived": "Enheten er uarkivert", "deviceUnarchivedDescription": "Enheten er blitt avarkivert.", "errorUnarchivingDevice": "Feil ved arkivering av enhet", "failedToUnarchiveDevice": "Kunne ikke fjerne arkivere enheten", "unarchive": "Avarkiver", "archiveClient": "Arkiver klient", "archiveClientQuestion": "Er du sikker på at du vil arkivere denne klienten?", "archiveClientMessage": "Klienten arkiveres og fjernes fra listen over aktive klienter.", "archiveClientConfirm": "Arkiver klient", "blockClient": "Blokker kunde", "blockClientQuestion": "Er du sikker på at du vil blokkere denne klienten?", "blockClientMessage": "Enheten blir tvunget til å koble fra hvis den er koblet til. Du kan fjerne blokkeringen av enheten senere.", "blockClientConfirm": "Blokker kunde", "active": "Aktiv", "usernameOrEmail": "Brukernavn eller e-post", "selectYourOrganization": "Velg din organisasjon", "signInTo": "Logg inn på", "signInWithPassword": "Fortsett med passord", "noAuthMethodsAvailable": "Ingen autentiseringsmetoder er tilgjengelige for denne organisasjonen.", "enterPassword": "Angi ditt passord", "enterMfaCode": "Angi koden fra din autentiseringsapp", "securityKeyRequired": "Vennligst bruk sikkerhetsnøkkelen til å logge på.", "needToUseAnotherAccount": "Trenger du å bruke en annen konto?", "loginLegalDisclaimer": "Ved å klikke på knappene nedenfor, erkjenner du at du har lest, forstår, og godtar Vilkår for bruk og for Personvernerklæring.", "termsOfService": "Vilkår for bruk", "privacyPolicy": "Retningslinjer for personvern", "userNotFoundWithUsername": "Ingen bruker med det brukernavnet funnet.", "verify": "Verifiser", "signIn": "Logg inn", "forgotPassword": "Glemt passord?", "orgSignInTip": "Hvis du har logget inn før, kan du skrive inn brukernavnet eller e-postadressen ovenfor for å autentisere med organisasjonens identitetstjeneste i stedet. Det er enklere!", "continueAnyway": "Fortsett likevel", "dontShowAgain": "Ikke vis igjen", "orgSignInNotice": "Visste du?", "signupOrgNotice": "Prøver å logge inn?", "signupOrgTip": "Prøver du å logge inn gjennom din organisasjons identitetsleverandør?", "signupOrgLink": "Logg inn eller registrer deg med organisasjonen din i stedet", "verifyEmailLogInWithDifferentAccount": "Bruk en annen konto", "logIn": "Logg inn", "deviceInformation": "Enhetens informasjon", "deviceInformationDescription": "Informasjon om enheten og agenten", "deviceSecurity": "Enhetens sikkerhet", "deviceSecurityDescription": "Sikkerhetsstillings informasjon om utstyr", "platform": "Plattform", "macosVersion": "macOS versjon", "windowsVersion": "Windows versjon", "iosVersion": "iOS Versjon", "androidVersion": "Android versjon", "osVersion": "OS versjon", "kernelVersion": "Kjerne versjon", "deviceModel": "Enhets modell", "serialNumber": "Serienummer", "hostname": "Hostname", "firstSeen": "Først sett", "lastSeen": "Sist sett", "biometricsEnabled": "Biometri aktivert", "diskEncrypted": "Disk kryptert", "firewallEnabled": "Brannmur aktivert", "autoUpdatesEnabled": "Automatiske oppdateringer aktivert", "tpmAvailable": "TPM tilgjengelig", "windowsAntivirusEnabled": "Antivirus aktivert", "macosSipEnabled": "System Integritetsbeskyttelse (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Brannmur Usynlig Modus", "linuxAppArmorEnabled": "Rustning", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "Vis enhetsinformasjon og innstillinger", "devicePendingApprovalDescription": "Denne enheten venter på godkjenning", "deviceBlockedDescription": "Denne enheten er blokkert. Det kan ikke kobles til noen ressurser med mindre de ikke blir blokkert.", "unblockClient": "Avblokker klient", "unblockClientDescription": "Enheten har blitt blokkert", "unarchiveClient": "Fjern arkivering klient", "unarchiveClientDescription": "Enheten er arkivert", "block": "Blokker", "unblock": "Avblokker", "deviceActions": "Enhetens handlinger", "deviceActionsDescription": "Administrer enhetsstatus og tilgang", "devicePendingApprovalBannerDescription": "Denne enheten venter på godkjenning. Den kan ikke koble til ressurser før den er godkjent.", "connected": "Tilkoblet", "disconnected": "Frakoblet", "approvalsEmptyStateTitle": "Enhetsgodkjenninger er ikke aktivert", "approvalsEmptyStateDescription": "Aktivere godkjenninger av enheter for at roller må godkjennes av admin før brukere kan koble til nye enheter.", "approvalsEmptyStateStep1Title": "Gå til roller", "approvalsEmptyStateStep1Description": "Naviger til organisasjonens roller innstillinger for å konfigurere enhetsgodkjenninger.", "approvalsEmptyStateStep2Title": "Aktiver enhetsgodkjenninger", "approvalsEmptyStateStep2Description": "Rediger en rolle og aktiver alternativet 'Kreve enhetsgodkjenninger'. Brukere med denne rollen vil trenge administratorgodkjenning for nye enheter.", "approvalsEmptyStatePreviewDescription": "Forhåndsvisning: Når aktivert, ventende enhets forespørsler vil vises her for vurdering", "approvalsEmptyStateButtonText": "Administrer Roller" } ================================================ FILE: messages/nl-NL.json ================================================ { "setupCreate": "Maak de organisatie, site en bronnen aan", "headerAuthCompatibilityInfo": "Schakel dit in om een 401 Niet Geautoriseerd antwoord af te dwingen wanneer een authenticatietoken ontbreekt. Dit is vereist voor browsers of specifieke HTTP-bibliotheken die geen referenties verzenden zonder een serveruitdaging.", "headerAuthCompatibility": "Uitgebreide compatibiliteit", "setupNewOrg": "Nieuwe organisatie", "setupCreateOrg": "Nieuwe organisatie aanmaken", "setupCreateResources": "Bronnen aanmaken", "setupOrgName": "Naam van de organisatie", "orgDisplayName": "Dit is de weergavenaam van de organisatie.", "orgId": "Organisatie ID", "setupIdentifierMessage": "Dit is de unieke identificatie voor de organisatie.", "setupErrorIdentifier": "Organisatie-ID is al in gebruik. Kies een andere.", "componentsErrorNoMemberCreate": "U bent momenteel geen lid van een organisatie. Maak een organisatie aan om aan de slag te gaan.", "componentsErrorNoMember": "U bent momenteel geen lid van een organisatie.", "welcome": "Welkom bij Pangolin!", "welcomeTo": "Welkom bij", "componentsCreateOrg": "Maak een Organisatie", "componentsMember": "Je bent lid van {count, plural, =0 {geen organisatie} one {één organisatie} other {# organisaties}}.", "componentsInvalidKey": "Ongeldige of verlopen licentiesleutels gedetecteerd. Volg de licentievoorwaarden om alle functies te blijven gebruiken.", "dismiss": "Uitschakelen", "subscriptionViolationMessage": "U overschrijdt uw huidige abonnement. Corrigeer het probleem door sites, gebruikers of andere bronnen te verwijderen om binnen uw plan te blijven.", "subscriptionViolationViewBilling": "Facturering bekijken", "componentsLicenseViolation": "Licentie overtreding: Deze server gebruikt {usedSites} sites die de gelicentieerde limiet van {maxSites} sites overschrijden. Volg de licentievoorwaarden om door te gaan met het gebruik van alle functies.", "componentsSupporterMessage": "Bedankt voor het ondersteunen van Pangolin als {tier}!", "inviteErrorNotValid": "Het spijt ons, maar de uitnodiging die je probeert te bezoeken is niet geaccepteerd of is niet meer geldig.", "inviteErrorUser": "Het spijt ons, maar de uitnodiging die u probeert te gebruiken is niet voor deze gebruiker.", "inviteLoginUser": "Controleer of je bent aangemeld als de juiste gebruiker.", "inviteErrorNoUser": "Het spijt ons, maar de uitnodiging die u probeert te gebruiken is niet voor een bestaande gebruiker.", "inviteCreateUser": "U moet eerst een account aanmaken.", "goHome": "Ga naar huis", "inviteLogInOtherUser": "Log in als een andere gebruiker", "createAnAccount": "Account aanmaken", "inviteNotAccepted": "Uitnodiging niet geaccepteerd", "authCreateAccount": "Maak een account aan om te beginnen", "authNoAccount": "Nog geen account?", "email": "E-mailadres", "password": "Wachtwoord", "confirmPassword": "Bevestig wachtwoord", "createAccount": "Account Aanmaken", "viewSettings": "Instellingen weergeven", "delete": "Verwijderen", "name": "Naam", "online": "Online", "offline": "Offline", "site": "Referentie", "dataIn": "Dataverbruik inkomend", "dataOut": "Dataverbruik uitgaand", "connectionType": "Type verbinding", "tunnelType": "Tunnel type", "local": "Lokaal", "edit": "Bewerken", "siteConfirmDelete": "Verwijderen van site bevestigen", "siteDelete": "Site verwijderen", "siteMessageRemove": "Eenmaal verwijderd zal de site niet langer toegankelijk zijn. Alle aan de site gekoppelde doelen zullen ook worden verwijderd.", "siteQuestionRemove": "Weet u zeker dat u de site wilt verwijderen uit de organisatie?", "siteManageSites": "Sites beheren", "siteDescription": "Maak en beheer sites om verbinding met privénetwerken in te schakelen", "sitesBannerTitle": "Verbind elk netwerk", "sitesBannerDescription": "Een site is een verbinding met een extern netwerk waarmee Pangolin toegang biedt tot bronnen, zowel openbaar als privé, aan gebruikers overal. Installeer de sitedatacenterconnector (Newt) overal waar je een binaire of container kunt uitvoeren om de verbinding tot stand te brengen.", "sitesBannerButtonText": "Site installeren", "approvalsBannerTitle": "Toegang tot het apparaat goedkeuren of weigeren", "approvalsBannerDescription": "Bekijk en keur toestelverzoeken goed of weiger toegang van gebruikers. Wanneer apparaatgoedkeuringen vereist zijn, moeten gebruikers de goedkeuring van beheerders krijgen voordat hun apparaten verbinding kunnen maken met de bronnen van uw organisatie.", "approvalsBannerButtonText": "Meer informatie", "siteCreate": "Site maken", "siteCreateDescription2": "Volg de onderstaande stappen om een nieuwe site aan te maken en te verbinden", "siteCreateDescription": "Maak een nieuwe site aan om bronnen te verbinden", "close": "Sluiten", "siteErrorCreate": "Fout bij maken site", "siteErrorCreateKeyPair": "Key pair of site standaard niet gevonden", "siteErrorCreateDefaults": "Standaardinstellingen niet gevonden", "method": "Methode", "siteMethodDescription": "Op deze manier legt u verbindingen bloot.", "siteLearnNewt": "Leer hoe u Newt kunt installeren op uw systeem", "siteSeeConfigOnce": "U kunt de configuratie maar één keer zien.", "siteLoadWGConfig": "WireGuard configuratie wordt geladen...", "siteDocker": "Details Docker implementatie uitvouwen", "toggle": "Omschakelen", "dockerCompose": "Docker opstellen", "dockerRun": "Docker Uitvoeren", "siteLearnLocal": "Lokale sites doen geen tunnel, leren meer", "siteConfirmCopy": "Ik heb de configuratie gekopieerd", "searchSitesProgress": "Sites zoeken...", "siteAdd": "Site toevoegen", "siteInstallNewt": "Installeer Newt", "siteInstallNewtDescription": "Laat Newt draaien op uw systeem", "WgConfiguration": "WireGuard Configuratie", "WgConfigurationDescription": "Gebruik de volgende configuratie om verbinding te maken met het netwerk", "operatingSystem": "Operating systeem", "commands": "Opdrachten", "recommended": "Aanbevolen", "siteNewtDescription": "Gebruik Newt voor de beste gebruikerservaring. Het maakt gebruik van WireGuard onder de capuchon en laat je toe om contact op te nemen met je privébronnen via hun LAN-adres op je privénetwerk vanuit het Pangolin dashboard.", "siteRunsInDocker": "Loopt in Docker", "siteRunsInShell": "Voert in shell op macOS, Linux en Windows", "siteErrorDelete": "Fout bij verwijderen site", "siteErrorUpdate": "Bijwerken site mislukt", "siteErrorUpdateDescription": "Fout opgetreden tijdens het bijwerken van de site.", "siteUpdated": "Site bijgewerkt", "siteUpdatedDescription": "De site is bijgewerkt.", "siteGeneralDescription": "Algemene instellingen voor deze site configureren", "siteSettingDescription": "Configureer de instellingen van de site", "siteSetting": "{siteName} instellingen", "siteNewtTunnel": "Nieuwste site (Aanbevolen)", "siteNewtTunnelDescription": "Makkelijkste manier om een ingangspunt in een netwerk te maken. Geen extra opzet.", "siteWg": "Basis WireGuard", "siteWgDescription": "Gebruik een WireGuard client om een tunnel te bouwen. Handmatige NAT installatie vereist.", "siteWgDescriptionSaas": "Gebruik elke WireGuard-client om een tunnel op te zetten. Handmatige NAT-instelling vereist. WERKT ALLEEN OP SELF HOSTED NODES", "siteLocalDescription": "Alleen lokale bronnen. Geen tunneling.", "siteLocalDescriptionSaas": "Enkel lokale bronnen. Geen tunneling. Alleen beschikbaar op externe knooppunten.", "siteSeeAll": "Alle sites bekijken", "siteTunnelDescription": "Bepaal hoe u verbinding wilt maken met de site", "siteNewtCredentials": "Aanmeldgegevens", "siteNewtCredentialsDescription": "Dit is hoe de site zich zal verifiëren met de server", "remoteNodeCredentialsDescription": "Dit is hoe de externe node zich bij de server zal authenticeren", "siteCredentialsSave": "Sla de aanmeldgegevens op", "siteCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.", "siteInfo": "Site informatie", "status": "Status", "shareTitle": "Beheer deellinks", "shareDescription": "Maak deelbare links aan om tijdelijke of permanente toegang tot proxybronnen te verlenen", "shareSearch": "Zoek share links...", "shareCreate": "Maak Share link", "shareErrorDelete": "Kan link niet verwijderen", "shareErrorDeleteMessage": "Fout opgetreden tijdens het verwijderen link", "shareDeleted": "Link verwijderd", "shareDeletedDescription": "De link is verwijderd", "shareTokenDescription": "De toegangstoken kan op twee manieren worden doorgegeven: als queryparameter of in de aanvraagheaders. Deze moeten worden doorgegeven van de client op elk verzoek voor geverifieerde toegang.", "accessToken": "Toegangs-token", "usageExamples": "Voorbeelden van gebruik", "tokenId": "Token ID", "requestHeades": "Aanvraag van headers", "queryParameter": "Queryparameter", "importantNote": "Belangrijke opmerking", "shareImportantDescription": "Om veiligheidsredenen wordt het gebruik van headers aanbevolen over queryparameters indien mogelijk, omdat query parameters kunnen worden aangemeld in serverlogboeken of browsergeschiedenis.", "token": "Token", "shareTokenSecurety": "Houd het toegangstoken beveiligd. Deel het niet in openbaar toegankelijke gebieden of client-side code.", "shareErrorFetchResource": "Fout bij het ophalen van bronnen", "shareErrorFetchResourceDescription": "Er is een fout opgetreden bij het ophalen van de resources", "shareErrorCreate": "Aanmaken van link delen mislukt", "shareErrorCreateDescription": "Fout opgetreden tijdens het maken van de share link", "shareCreateDescription": "Iedereen met deze link heeft toegang tot de pagina", "shareTitleOptional": "Titel (optioneel)", "expireIn": "Vervalt in", "neverExpire": "Nooit verlopen", "shareExpireDescription": "Vervaltijd is hoe lang de link bruikbaar is en geeft toegang tot de bron. Na deze tijd zal de link niet meer werken en zullen gebruikers die deze link hebben gebruikt de toegang tot de pagina verliezen.", "shareSeeOnce": "U kunt deze link slechts één keer zien. Zorg ervoor dat u deze kopieert.", "shareAccessHint": "Iedereen met deze link heeft toegang tot de bron. Deel deze met zorg.", "shareTokenUsage": "Zie Toegangstoken Gebruik", "createLink": "Koppeling aanmaken", "resourcesNotFound": "Geen bronnen gevonden", "resourceSearch": "Zoek bronnen", "openMenu": "Menu openen", "resource": "Bron", "title": "Aanspreektitel", "created": "Aangemaakt", "expires": "Verloopt", "never": "Nooit", "shareErrorSelectResource": "Selecteer een bron", "proxyResourceTitle": "Openbare bronnen beheren", "proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser", "proxyResourcesBannerTitle": "Webgebaseerde openbare toegang", "proxyResourcesBannerDescription": "Openbare bronnen zijn HTTPS of TCP/UDP-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot priv��bronnen vereisen ze geen client-side software maar kunnen ze identiteits- en context-bewuste toegangsrichtlijnen bevatten.", "clientResourceTitle": "Privébronnen beheren", "clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client", "privateResourcesBannerTitle": "Zero-Trust Private Access", "privateResourcesBannerDescription": "Privé bronnen maken gebruik van zero-trust-beveiliging, wat ervoor zorgt dat gebruikers en machines alleen toegang kunnen krijgen tot middelen die jij specifiek toestaat. Verbind gebruikersapparaten of machineclients om deze middelen te benaderen via een veilig virtueel priv��netwerk.", "resourcesSearch": "Zoek bronnen...", "resourceAdd": "Bron toevoegen", "resourceErrorDelte": "Fout bij verwijderen document", "authentication": "Authenticatie", "protected": "Beschermd", "notProtected": "Niet beveiligd", "resourceMessageRemove": "Eenmaal verwijderd, zal het bestand niet langer toegankelijk zijn. Alle doelen die gekoppeld zijn aan het hulpbron, zullen ook verwijderd worden.", "resourceQuestionRemove": "Weet u zeker dat u het document van de organisatie wilt verwijderen?", "resourceHTTP": "HTTPS bron", "resourceHTTPDescription": "Proxyverzoeken via HTTPS met een volledig gekwalificeerde domeinnaam.", "resourceRaw": "TCP/UDP bron", "resourceRawDescription": "Proxyverzoeken via ruwe TCP/UDP met een poortnummer.", "resourceRawDescriptionCloud": "Proxy vraagt om onbewerkte TCP/UDP met behulp van een poortnummer. VEREIST HET GEBRUIK VAN EEN AFSTANDSBEDIENING NODE.", "resourceCreate": "Bron maken", "resourceCreateDescription": "Volg de onderstaande stappen om een nieuwe bron te maken", "resourceSeeAll": "Alle bronnen bekijken", "resourceInfo": "Bron informatie", "resourceNameDescription": "Dit is de weergavenaam voor het document.", "siteSelect": "Selecteer site", "siteSearch": "Zoek site", "siteNotFound": "Geen site gevonden.", "selectCountry": "Selecteer land", "searchCountries": "Zoek landen...", "noCountryFound": "Geen land gevonden.", "siteSelectionDescription": "Deze site zal connectiviteit met het doelwit bieden.", "resourceType": "Type bron", "resourceTypeDescription": "Bepaal hoe u toegang wilt tot de bron", "resourceHTTPSSettings": "HTTPS instellingen", "resourceHTTPSSettingsDescription": "Stel in hoe de bron wordt benaderd via HTTPS", "domainType": "Domein type", "subdomain": "Subdomein", "baseDomain": "Basis domein", "subdomnainDescription": "Het subdomein waar de bron toegankelijk zal zijn.", "resourceRawSettings": "TCP/UDP instellingen", "resourceRawSettingsDescription": "Stel in hoe de bron wordt benaderd via TCP/UDP", "protocol": "Protocol", "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", "resourceAddEntrypoints": "Traefik: Entrypoints toevoegen", "resourceExposePorts": "Gerbild: Gevangen blootstellen in Docker Compose", "resourceLearnRaw": "Leer hoe je TCP/UDP bronnen kunt configureren", "resourceBack": "Terug naar bronnen", "resourceGoTo": "Ga naar Resource", "resourceDelete": "Document verwijderen", "resourceDeleteConfirm": "Bevestig Verwijderen Document", "visibility": "Zichtbaarheid", "enabled": "Ingeschakeld", "disabled": "Uitgeschakeld", "general": "Algemeen", "generalSettings": "Algemene instellingen", "proxy": "Proxy", "internal": "Intern", "rules": "Regels", "resourceSettingDescription": "Configureer de instellingen in de bron", "resourceSetting": "{resourceName} instellingen", "alwaysAllow": "Authenticatie omzeilen", "alwaysDeny": "Blokkeer toegang", "passToAuth": "Passeren naar Auth", "orgSettingsDescription": "Configureer de instellingen van de organisatie", "orgGeneralSettings": "Organisatie Instellingen", "orgGeneralSettingsDescription": "De details en configuratie van de organisatie beheren", "saveGeneralSettings": "Algemene instellingen opslaan", "saveSettings": "Instellingen opslaan", "orgDangerZone": "Gevaarlijke zone", "orgDangerZoneDescription": "Deze instantie verwijderen is onomkeerbaar. Bevestig alstublieft dat u wilt doorgaan.", "orgDelete": "Verwijder organisatie", "orgDeleteConfirm": "Bevestig Verwijderen Organisatie", "orgMessageRemove": "Deze actie is onomkeerbaar en zal alle bijbehorende gegevens verwijderen.", "orgMessageConfirm": "Om te bevestigen, typ de naam van de onderstaande organisatie in.", "orgQuestionRemove": "Weet u zeker dat u de organisatie wilt verwijderen?", "orgUpdated": "Organisatie bijgewerkt", "orgUpdatedDescription": "De organisatie is bijgewerkt.", "orgErrorUpdate": "Bijwerken organisatie mislukt", "orgErrorUpdateMessage": "Fout opgetreden tijdens het bijwerken van de organisatie.", "orgErrorFetch": "Organisaties ophalen mislukt", "orgErrorFetchMessage": "Er is een fout opgetreden tijdens het plaatsen van uw organisaties", "orgErrorDelete": "Kan organisatie niet verwijderen", "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", "accessUsersDescription": "Nodig uit en beheer gebruikers met toegang tot deze organisatie", "accessUsersSearch": "Gebruikers zoeken...", "accessUserCreate": "Gebruiker aanmaken", "accessUserRemove": "Gebruiker verwijderen", "username": "Gebruikersnaam", "identityProvider": "Identiteit Provider", "role": "Functie", "nameRequired": "Naam is verplicht", "accessRolesManage": "Rollen beheren", "accessRolesDescription": "Maak en beheer rollen voor gebruikers in de organisatie", "accessRolesSearch": "Rollen zoeken...", "accessRolesAdd": "Rol toevoegen", "accessRoleDelete": "Verwijder rol", "accessApprovalsManage": "Goedkeuringen beheren", "accessApprovalsDescription": "Bekijk en beheer openstaande goedkeuringen voor toegang tot deze organisatie", "description": "Beschrijving", "inviteTitle": "Open uitnodigingen", "inviteDescription": "Beheer uitnodigingen voor andere gebruikers om deel te nemen aan de organisatie", "inviteSearch": "Uitnodigingen zoeken...", "minutes": "minuten", "hours": "Uren", "days": "dagen", "weeks": "Weken", "months": "maanden", "years": "Jaar", "day": "{count, plural, one {# dag} other {# dagen}}", "apiKeysTitle": "API Key Informatie", "apiKeysConfirmCopy2": "Bevestig dat u de API-sleutel hebt gekopieerd.", "apiKeysErrorCreate": "Fout bij maken API-sleutel", "apiKeysErrorSetPermission": "Fout instellen permissies", "apiKeysCreate": "API-sleutel genereren", "apiKeysCreateDescription": "Een nieuwe API-sleutel voor de organisatie genereren", "apiKeysGeneralSettings": "Machtigingen", "apiKeysGeneralSettingsDescription": "Bepaal wat deze API-sleutel kan doen", "apiKeysList": "Nieuwe API-sleutel", "apiKeysSave": "De API-sleutel opslaan", "apiKeysSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een veilige plek.", "apiKeysInfo": "De API-sleutel is:", "apiKeysConfirmCopy": "Ik heb de API-sleutel gekopieerd", "generate": "Genereren", "done": "Voltooid", "apiKeysSeeAll": "Alle API-sleutels bekijken", "apiKeysPermissionsErrorLoadingActions": "Fout bij het laden van API key acties", "apiKeysPermissionsErrorUpdate": "Fout instellen permissies", "apiKeysPermissionsUpdated": "Permissies bijgewerkt", "apiKeysPermissionsUpdatedDescription": "De bevoegdheden zijn bijgewerkt.", "apiKeysPermissionsGeneralSettings": "Machtigingen", "apiKeysPermissionsGeneralSettingsDescription": "Bepaal wat deze API-sleutel kan doen", "apiKeysPermissionsSave": "Rechten opslaan", "apiKeysPermissionsTitle": "Machtigingen", "apiKeys": "API sleutels", "searchApiKeys": "API-sleutels zoeken...", "apiKeysAdd": "API-sleutel genereren", "apiKeysErrorDelete": "Fout bij verwijderen API-sleutel", "apiKeysErrorDeleteMessage": "Fout bij verwijderen API-sleutel", "apiKeysQuestionRemove": "Weet u zeker dat u de API-sleutel van de organisatie wilt verwijderen?", "apiKeysMessageRemove": "Eenmaal verwijderd, kan de API-sleutel niet meer worden gebruikt.", "apiKeysDeleteConfirm": "Bevestig Verwijderen API-sleutel", "apiKeysDelete": "API-sleutel verwijderen", "apiKeysManage": "API-sleutels beheren", "apiKeysDescription": "API-sleutels worden gebruikt om te verifiëren met de integratie-API", "apiKeysSettings": "{apiKeyName} instellingen", "userTitle": "Alle gebruikers beheren", "userDescription": "Bekijk en beheer alle gebruikers in het systeem", "userAbount": "Over gebruikersbeheer", "userAbountDescription": "Deze tabel toont alle root user objecten in het systeem. Elke gebruiker kan tot meerdere organisaties behoren. Een gebruiker verwijderen uit een organisatie verwijdert hun root gebruiker object niet - ze zullen in het systeem blijven. Om een gebruiker volledig te verwijderen uit het systeem, moet u hun root gebruiker object verwijderen met behulp van de actie in deze tabel.", "userServer": "Server Gebruikers", "userSearch": "Zoek server gebruikers...", "userErrorDelete": "Fout bij verwijderen gebruiker", "userDeleteConfirm": "Bevestig verwijderen gebruiker", "userDeleteServer": "Gebruiker verwijderen van de server", "userMessageRemove": "De gebruiker zal uit alle organisaties verwijderd worden en volledig verwijderd worden van de server.", "userQuestionRemove": "Weet u zeker dat u de gebruiker permanent van de server wilt verwijderen?", "licenseKey": "Licentie sleutel", "valid": "Geldig", "numberOfSites": "Aantal sites", "licenseKeySearch": "Licentiesleutels zoeken...", "licenseKeyAdd": "Licentiesleutel toevoegen", "type": "Type", "licenseKeyRequired": "Licentiesleutel is vereist", "licenseTermsAgree": "U moet akkoord gaan met de licentievoorwaarden", "licenseErrorKeyLoad": "Laden van licentiesleutels mislukt", "licenseErrorKeyLoadDescription": "Er is een fout opgetreden bij het laden van licentiecodes.", "licenseErrorKeyDelete": "Licentiesleutel verwijderen mislukt", "licenseErrorKeyDeleteDescription": "Er is een fout opgetreden bij het verwijderen van licentiesleutel.", "licenseKeyDeleted": "Licentiesleutel verwijderd", "licenseKeyDeletedDescription": "De licentiesleutel is verwijderd.", "licenseErrorKeyActivate": "Licentiesleutel activeren mislukt", "licenseErrorKeyActivateDescription": "Er is een fout opgetreden tijdens het activeren van de licentiesleutel.", "licenseAbout": "Over licenties", "communityEdition": "Community editie", "licenseAboutDescription": "Dit geldt voor gebruikers van bedrijven en ondernemingen die Pangolin in gebruiken in een commerciële omgeving. Als u Pangolin gebruikt voor persoonlijk gebruik, kunt u dit gedeelte negeren.", "licenseKeyActivated": "Licentiesleutel geactiveerd", "licenseKeyActivatedDescription": "De licentiesleutel is geactiveerd.", "licenseErrorKeyRecheck": "Kon licentiesleutels niet opnieuw controleren", "licenseErrorKeyRecheckDescription": "Er is een fout opgetreden bij het opnieuw controleren van licentiecodes.", "licenseErrorKeyRechecked": "Licentiesleutels opnieuw gecontroleerd", "licenseErrorKeyRecheckedDescription": "Alle licentiesleutels zijn opnieuw gecontroleerd", "licenseActivateKey": "Activeer licentiesleutel", "licenseActivateKeyDescription": "Voer een licentiesleutel in om deze te activeren.", "licenseActivate": "Licentie activeren", "licenseAgreement": "Door dit selectievakje aan te vinken, bevestigt u dat u de licentievoorwaarden hebt gelezen en ermee akkoord gaat die overeenkomen met de rang die is gekoppeld aan uw licentiesleutel.", "fossorialLicense": "Fossorial Commerciële licentie- en abonnementsvoorwaarden bekijken", "licenseMessageRemove": "Dit zal de licentiesleutel en alle bijbehorende machtigingen verwijderen die hierdoor zijn verleend.", "licenseMessageConfirm": "Typ de licentiesleutel hieronder om te bevestigen.", "licenseQuestionRemove": "Weet u zeker dat u de licentiesleutel wilt verwijderen?", "licenseKeyDelete": "Licentiesleutel verwijderen", "licenseKeyDeleteConfirm": "Bevestig verwijderen licentiesleutel", "licenseTitle": "Licentiestatus beheren", "licenseTitleDescription": "Bekijk en beheer licentiesleutels in het systeem", "licenseHost": "Host Licentie", "licenseHostDescription": "Beheer de belangrijkste licentiesleutel voor de host.", "licensedNot": "Niet gelicentieerd", "hostId": "Host-ID", "licenseReckeckAll": "Alle sleutels opnieuw selecteren", "licenseSiteUsage": "Websites gebruik", "licenseSiteUsageDecsription": "Bekijk het aantal sites dat deze licentie gebruikt.", "licenseNoSiteLimit": "Er is geen limiet op het aantal sites dat een ongelicentieerde host gebruikt.", "licensePurchase": "Licentie kopen", "licensePurchaseSites": "Extra sites kopen", "licenseSitesUsedMax": "{usedSites} van {maxSites} sites gebruikt", "licenseSitesUsed": "{count, plural, =0 {# locaties} one {# locatie} other {# locaties}} in het systeem.", "licensePurchaseDescription": "Kies hoeveel sites je wilt {selectedMode, select, license {Koop een licentie. Je kunt later altijd meer sites toevoegen.} other {Voeg je bestaande licentie toe}}", "licenseFee": "Licentie vergoeding", "licensePriceSite": "Prijs per site", "total": "Totaal", "licenseContinuePayment": "Doorgaan naar betaling", "pricingPage": "prijsaanduiding pagina", "pricingPortal": "Inkoopportaal bekijken", "licensePricingPage": "Bezoek voor de meest recente prijzen en kortingen, a.u.b. de ", "invite": "Uitnodigingen", "inviteRegenerate": "Uitnodiging opnieuw genereren", "inviteRegenerateDescription": "Verwijder vorige uitnodiging en maak een nieuwe", "inviteRemove": "Verwijder uitnodiging", "inviteRemoveError": "Uitnodiging verwijderen mislukt", "inviteRemoveErrorDescription": "Er is een fout opgetreden tijdens het verwijderen van de uitnodiging.", "inviteRemoved": "Uitnodiging verwijderd", "inviteRemovedDescription": "De uitnodiging voor {email} is verwijderd.", "inviteQuestionRemove": "Weet je zeker dat je de uitnodiging wilt verwijderen?", "inviteMessageRemove": "Eenmaal verwijderd, zal deze uitnodiging niet meer geldig zijn. U kunt de gebruiker later altijd opnieuw uitnodigen.", "inviteMessageConfirm": "Om dit te bevestigen, typ dan het e-mailadres van onderstaande uitnodiging.", "inviteQuestionRegenerate": "Weet u zeker dat u de uitnodiging voor {email}opnieuw wilt genereren? Dit zal de vorige uitnodiging intrekken.", "inviteRemoveConfirm": "Bevestig verwijderen uitnodiging", "inviteRegenerated": "Uitnodiging opnieuw gegenereerd", "inviteSent": "Een nieuwe uitnodiging is verstuurd naar {email}.", "inviteSentEmail": "Stuur e-mail notificatie naar de gebruiker", "inviteGenerate": "Er is een nieuwe uitnodiging aangemaakt voor {email}.", "inviteDuplicateError": "Dubbele uitnodiging", "inviteDuplicateErrorDescription": "Er bestaat al een uitnodiging voor deze gebruiker.", "inviteRateLimitError": "Tarief limiet overschreden", "inviteRateLimitErrorDescription": "U hebt de limiet van 3 regeneratie per uur overschreden. Probeer het later opnieuw.", "inviteRegenerateError": "Kan uitnodiging niet opnieuw aanmaken", "inviteRegenerateErrorDescription": "Fout opgetreden tijdens het opnieuw genereren van de uitnodiging.", "inviteValidityPeriod": "Geldigheid periode", "inviteValidityPeriodSelect": "Geldigheid kiezen", "inviteRegenerateMessage": "De uitnodiging is opnieuw gegenereerd. De gebruiker moet toegang krijgen tot de link hieronder om de uitnodiging te accepteren.", "inviteRegenerateButton": "Hergenereren", "expiresAt": "Verloopt op", "accessRoleUnknown": "Onbekende rol", "placeholder": "Plaatsaanduiding", "userErrorOrgRemove": "Kan gebruiker niet verwijderen", "userErrorOrgRemoveDescription": "Er is een fout opgetreden tijdens het verwijderen van de gebruiker.", "userOrgRemoved": "Gebruiker verwijderd", "userOrgRemovedDescription": "De gebruiker {email} is verwijderd uit de organisatie.", "userQuestionOrgRemove": "Weet u zeker dat u deze gebruiker wilt verwijderen uit de organisatie?", "userMessageOrgRemove": "Eenmaal verwijderd, heeft deze gebruiker geen toegang meer tot de organisatie. Je kunt ze later altijd opnieuw uitnodigen, maar ze zullen de uitnodiging opnieuw moeten accepteren.", "userRemoveOrgConfirm": "Bevestig verwijderen gebruiker", "userRemoveOrg": "Gebruiker uit organisatie verwijderen", "users": "Gebruikers", "accessRoleMember": "Lid", "accessRoleOwner": "Eigenaar", "userConfirmed": "Bevestigd", "idpNameInternal": "Intern", "emailInvalid": "Ongeldig e-mailadres", "inviteValidityDuration": "Selecteer een tijdsduur", "accessRoleSelectPlease": "Selecteer een rol", "usernameRequired": "Gebruikersnaam is verplicht", "idpSelectPlease": "Selecteer een identiteitsprovider", "idpGenericOidc": "Algemene OAuth2/OIDC provider.", "accessRoleErrorFetch": "Rollen ophalen mislukt", "accessRoleErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van de rollen", "idpErrorFetch": "Kan identiteitsaanbieders niet ophalen", "idpErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van identiteitsproviders", "userErrorExists": "Gebruiker bestaat al", "userErrorExistsDescription": "Deze gebruiker is al lid van de organisatie.", "inviteError": "Uitnodigen van gebruiker mislukt", "inviteErrorDescription": "Er is een fout opgetreden tijdens het uitnodigen van de gebruiker", "userInvited": "Gebruiker uitgenodigd", "userInvitedDescription": "De gebruiker is succesvol uitgenodigd.", "userErrorCreate": "Gebruiker aanmaken mislukt", "userErrorCreateDescription": "Fout opgetreden tijdens het aanmaken van de gebruiker", "userCreated": "Gebruiker aangemaakt", "userCreatedDescription": "De gebruiker is succesvol aangemaakt.", "userTypeInternal": "Interne gebruiker", "userTypeInternalDescription": "Nodig een gebruiker uit om direct lid te worden van de organisatie.", "userTypeExternal": "Externe gebruiker", "userTypeExternalDescription": "Maak een gebruiker aan met een externe identiteitsprovider.", "accessUserCreateDescription": "Volg de onderstaande stappen om een nieuwe gebruiker te maken", "userSeeAll": "Alle gebruikers bekijken", "userTypeTitle": "Type gebruiker", "userTypeDescription": "Bepaal hoe u de gebruiker wilt aanmaken", "userSettings": "Gebruikers informatie", "userSettingsDescription": "Voer de gegevens van de nieuwe gebruiker in", "inviteEmailSent": "Stuur uitnodigingsmail naar de gebruiker", "inviteValid": "Geldig voor", "selectDuration": "Selecteer duur", "selectResource": "Selecteer Document", "filterByResource": "Filter op pagina", "selectApprovalState": "Selecteer goedkeuringsstatus", "filterByApprovalState": "Filter op goedkeuringsstatus", "approvalListEmpty": "Geen goedkeuringen", "approvalState": "Goedkeuring status", "approvalLoadMore": "Meer laden", "loadingApprovals": "Goedkeuringen laden", "approve": "Goedkeuren", "approved": "Goedgekeurd", "denied": "Geweigerd", "deniedApproval": "Geweigerde goedkeuring", "all": "Alles", "deny": "Weigeren", "viewDetails": "Details bekijken", "requestingNewDeviceApproval": "heeft een nieuw apparaat aangevraagd", "resetFilters": "Filters resetten", "totalBlocked": "Verzoeken geblokkeerd door Pangolin", "totalRequests": "Totaal verzoeken", "requestsByCountry": "Verzoeken per land", "requestsByDay": "Verzoeken per dag", "blocked": "Geblokkeerd", "allowed": "Toegestaan", "topCountries": "Top Landen", "accessRoleSelect": "Selecteer rol", "inviteEmailSentDescription": "Een e-mail is verstuurd naar de gebruiker met de link hieronder. Ze moeten toegang krijgen tot de link om de uitnodiging te accepteren.", "inviteSentDescription": "De gebruiker is uitgenodigd. Ze moeten toegang krijgen tot de link hieronder om de uitnodiging te accepteren.", "inviteExpiresIn": "De uitnodiging vervalt over {days, plural, one {# dag} other {# dagen}}.", "idpTitle": "Identiteit Provider", "idpSelect": "Identiteitsprovider voor de externe gebruiker selecteren", "idpNotConfigured": "Er zijn geen identiteitsproviders geconfigureerd. Configureer een identiteitsprovider voordat u externe gebruikers aanmaakt.", "usernameUniq": "Dit moet overeenkomen met de unieke gebruikersnaam die bestaat in de geselecteerde identiteitsprovider.", "emailOptional": "E-mailadres (optioneel)", "nameOptional": "Naam (optioneel)", "accessControls": "Toegang Bediening", "userDescription2": "Beheer de instellingen van deze gebruiker", "accessRoleErrorAdd": "Gebruiker aan rol toevoegen mislukt", "accessRoleErrorAddDescription": "Er is een fout opgetreden tijdens het toevoegen van de rol.", "userSaved": "Gebruiker opgeslagen", "userSavedDescription": "De gebruiker is bijgewerkt.", "autoProvisioned": "Automatisch bevestigen", "autoProvisionedDescription": "Toestaan dat deze gebruiker automatisch wordt beheerd door een identiteitsprovider", "accessControlsDescription": "Beheer wat deze gebruiker toegang heeft tot en doet in de organisatie", "accessControlsSubmit": "Bewaar Toegangsbesturing", "roles": "Rollen", "accessUsersRoles": "Beheer Gebruikers & Rollen", "accessUsersRolesDescription": "Nodig gebruikers uit en voeg ze toe aan de rollen om toegang tot de organisatie te beheren", "key": "Sleutel", "createdAt": "Aangemaakt op", "proxyErrorInvalidHeader": "Ongeldige aangepaste Header waarde. Gebruik het domeinnaam formaat, of sla leeg op om de aangepaste Host header ongedaan te maken.", "proxyErrorTls": "Ongeldige TLS servernaam. Gebruik de domeinnaam of sla leeg op om de TLS servernaam te verwijderen.", "proxyEnableSSL": "SSL inschakelen", "proxyEnableSSLDescription": "SSL/TLS-versleuteling inschakelen voor beveiligde HTTPS-verbindingen naar de doelen.", "target": "Target", "configureTarget": "Doelstellingen configureren", "targetErrorFetch": "Ophalen van doelen mislukt", "targetErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van de objecten", "siteErrorFetch": "Mislukt om resource op te halen", "siteErrorFetchDescription": "Er is een fout opgetreden tijdens het ophalen van het document", "targetErrorDuplicate": "Dubbel doelwit", "targetErrorDuplicateDescription": "Een doel met deze instellingen bestaat al", "targetWireGuardErrorInvalidIp": "Ongeldig doel-IP", "targetWireGuardErrorInvalidIpDescription": "Doel IP moet binnen de site subnet zijn", "targetsUpdated": "Doelstellingen bijgewerkt", "targetsUpdatedDescription": "Doelstellingen en instellingen succesvol bijgewerkt", "targetsErrorUpdate": "Kan doelen niet bijwerken", "targetsErrorUpdateDescription": "Fout opgetreden tijdens het bijwerken van de doelen", "targetTlsUpdate": "TLS instellingen bijgewerkt", "targetTlsUpdateDescription": "TLS instellingen zijn succesvol bijgewerkt", "targetErrorTlsUpdate": "Bijwerken van TLS instellingen mislukt", "targetErrorTlsUpdateDescription": "Fout opgetreden tijdens het bijwerken van de TLS-instellingen", "proxyUpdated": "Proxyinstellingen bijgewerkt", "proxyUpdatedDescription": "Proxyinstellingen zijn succesvol bijgewerkt", "proxyErrorUpdate": "Bijwerken van proxy-instellingen mislukt", "proxyErrorUpdateDescription": "Fout opgetreden tijdens het bijwerken van de proxy-instellingen", "targetAddr": "Hostnaam", "targetPort": "Poort", "targetProtocol": "Protocol", "targetTlsSettings": "HTTPS & TLS instellingen", "targetTlsSettingsDescription": "Configureer SSL/TLS instellingen voor de bron", "targetTlsSettingsAdvanced": "Geavanceerde TLS instellingen", "targetTlsSni": "TLS servernaam", "targetTlsSniDescription": "De TLS servernaam om te gebruiken voor SNI. Laat leeg om de standaard te gebruiken.", "targetTlsSubmit": "Instellingen opslaan", "targets": "Doelstellingen configuratie", "targetsDescription": "Stel doelen in om verkeer naar backend diensten te sturen", "targetStickySessions": "Sticky sessies inschakelen", "targetStickySessionsDescription": "Behoud verbindingen op hetzelfde backend doel voor hun hele sessie.", "methodSelect": "Selecteer methode", "targetSubmit": "Doelwit toevoegen", "targetNoOne": "Deze bron heeft geen doelwitten. Voeg een doel toe om te configureren waar verzoeken naar de backend verzonden kunnen worden.", "targetNoOneDescription": "Het toevoegen van meer dan één doel hierboven zal de load balancering mogelijk maken.", "targetsSubmit": "Doelstellingen opslaan", "addTarget": "Doelwit toevoegen", "targetErrorInvalidIp": "Ongeldig IP-adres", "targetErrorInvalidIpDescription": "Voer een geldig IP-adres of hostnaam in", "targetErrorInvalidPort": "Ongeldige poort", "targetErrorInvalidPortDescription": "Voer een geldig poortnummer in", "targetErrorNoSite": "Geen site geselecteerd", "targetErrorNoSiteDescription": "Selecteer een site voor het doel", "targetCreated": "Doel aangemaakt", "targetCreatedDescription": "Doel is succesvol aangemaakt", "targetErrorCreate": "Kan doel niet aanmaken", "targetErrorCreateDescription": "Fout opgetreden tijdens het aanmaken van het doel", "tlsServerName": "TLS servernaam", "tlsServerNameDescription": "De TLS servernaam om te gebruiken voor SNI", "save": "Opslaan", "proxyAdditional": "Extra Proxy-instellingen", "proxyAdditionalDescription": "Configureer hoe de bron omgaat met proxy-instellingen", "proxyCustomHeader": "Aangepaste Host-header", "proxyCustomHeaderDescription": "De hostkop om in te stellen bij proxying verzoeken. Laat leeg om de standaard te gebruiken.", "proxyAdditionalSubmit": "Proxyinstellingen opslaan", "subnetMaskErrorInvalid": "Ongeldig subnet masker. Moet tussen 0 en 32 zijn.", "ipAddressErrorInvalidFormat": "Ongeldig IP-adresformaat", "ipAddressErrorInvalidOctet": "Ongeldige IP adres octet", "path": "Pad", "matchPath": "Overeenkomend pad", "ipAddressRange": "IP Bereik", "rulesErrorFetch": "Regels ophalen mislukt", "rulesErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van de regels", "rulesErrorDuplicate": "Dupliceer regel", "rulesErrorDuplicateDescription": "Een regel met deze instellingen bestaat al", "rulesErrorInvalidIpAddressRange": "Ongeldige CIDR", "rulesErrorInvalidIpAddressRangeDescription": "Voer een geldige CIDR waarde in", "rulesErrorInvalidUrl": "Ongeldige URL pad", "rulesErrorInvalidUrlDescription": "Voer een geldige URL padwaarde in", "rulesErrorInvalidIpAddress": "Ongeldig IP", "rulesErrorInvalidIpAddressDescription": "Voer een geldig IP-adres in", "rulesErrorUpdate": "Regels bijwerken mislukt", "rulesErrorUpdateDescription": "Fout opgetreden tijdens het bijwerken van de regels", "rulesUpdated": "Regels inschakelen", "rulesUpdatedDescription": "Regel evaluatie is bijgewerkt", "rulesMatchIpAddressRangeDescription": "Voer een adres in in het CIDR-formaat (bijv. 103.21.244.0/22)", "rulesMatchIpAddress": "Voer een IP-adres in (bijv. 103.21.244.12)", "rulesMatchUrl": "Voer een URL-pad of patroon in (bijv. /api/v1/todos of /api/v1/*)", "rulesErrorInvalidPriority": "Ongeldige prioriteit", "rulesErrorInvalidPriorityDescription": "Voer een geldige prioriteit in", "rulesErrorDuplicatePriority": "Dubbele prioriteiten", "rulesErrorDuplicatePriorityDescription": "Voer unieke prioriteiten in", "ruleUpdated": "Regels bijgewerkt", "ruleUpdatedDescription": "Regels met succes bijgewerkt", "ruleErrorUpdate": "Bewerking mislukt", "ruleErrorUpdateDescription": "Er is een fout opgetreden tijdens het opslaan", "rulesPriority": "Prioriteit", "rulesAction": "actie", "rulesMatchType": "Wedstrijd Type", "value": "Waarde", "rulesAbout": "Over regels", "rulesAboutDescription": "Regels stellen u in staat om de toegang tot het bestand te controleren op basis van een aantal criteria. U kunt regels maken om toegang te toestaan of weigeren op basis van IP-adres of URL pad.", "rulesActions": "acties", "rulesActionAlwaysAllow": "Altijd toegestaan: Omzeil alle authenticatiemethoden", "rulesActionAlwaysDeny": "Altijd weigeren: Blokkeer alle aanvragen, er kan geen verificatie worden geprobeerd", "rulesActionPassToAuth": "Doorgeven aan Auth: Toestaan dat authenticatiemethoden worden geprobeerd", "rulesMatchCriteria": "Overeenkomende criteria", "rulesMatchCriteriaIpAddress": "Overeenkomen met een specifiek IP-adres", "rulesMatchCriteriaIpAddressRange": "Overeenkomen met een bereik van IP-adressen in de CIDR-notatie", "rulesMatchCriteriaUrl": "Koppel een URL-pad of patroon", "rulesEnable": "Regels inschakelen", "rulesEnableDescription": "In- of uitschakelen van regelevaluatie voor deze bron", "rulesResource": "Configuratie Resource Regels", "rulesResourceDescription": "Regels instellen om toegang tot de bron te beheren", "ruleSubmit": "Regel toevoegen", "rulesNoOne": "Geen regels. Voeg een regel toe via het formulier.", "rulesOrder": "Regels worden in oplopende volgorde volgens prioriteit beoordeeld.", "rulesSubmit": "Regels opslaan", "resourceErrorCreate": "Fout bij maken document", "resourceErrorCreateDescription": "Er is een fout opgetreden bij het maken van het document", "resourceErrorCreateMessage": "Fout bij maken bron:", "resourceErrorCreateMessageDescription": "Er is een onverwachte fout opgetreden", "sitesErrorFetch": "Fout bij ophalen sites", "sitesErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van de sites", "domainsErrorFetch": "Fout bij ophalen domeinen", "domainsErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van de domeinen", "none": "geen", "unknown": "onbekend", "resources": "Bronnen", "resourcesDescription": "Bronnen zijn proxies voor applicaties die op het privénetwerk worden uitgevoerd. Maak een bron aan voor een HTTP/HTTPS of ruwe TCP/UDP-service op uw privénetwerk. Elke bron moet verbonden zijn met een site om private, beveiligde verbinding mogelijk te maken via een versleutelde WireGuard tunnel.", "resourcesWireGuardConnect": "Beveiligde verbinding met WireGuard versleuteling", "resourcesMultipleAuthenticationMethods": "Meerdere verificatiemethoden configureren", "resourcesUsersRolesAccess": "Gebruiker en rol-gebaseerde toegangsbeheer", "resourcesErrorUpdate": "Bron wisselen mislukt", "resourcesErrorUpdateDescription": "Er is een fout opgetreden tijdens het bijwerken van het document", "access": "Toegangsrechten", "accessControl": "Toegangs controle", "shareLink": "{resource} Share link", "resourceSelect": "Selecteer resource", "shareLinks": "Links delen", "share": "Deelbare links", "shareDescription2": "Maak deelbare links naar bronnen. Links bieden tijdelijke of onbeperkte toegang tot je bestand. U kunt de vervalduur van de link configureren wanneer u er een aanmaakt.", "shareEasyCreate": "Makkelijk te maken en te delen", "shareConfigurableExpirationDuration": "Configureerbare vervalduur", "shareSecureAndRevocable": "Veilig en herroepbaar", "nameMin": "De naam moet minstens {len} tekens bevatten.", "nameMax": "Naam mag niet langer zijn dan {len} tekens.", "sitesConfirmCopy": "Bevestig dat u de configuratie hebt gekopieerd.", "unknownCommand": "Onbekende opdracht", "newtErrorFetchReleases": "Kan release-informatie niet ophalen: {err}", "newtErrorFetchLatest": "Fout bij ophalen van laatste release: {err}", "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Geheim", "architecture": "Architectuur", "sites": "Sites", "siteWgAnyClients": "Gebruik een willekeurige WireGuard client om verbinding te maken. Je zult interne bronnen moeten aanspreken met behulp van de peer IP.", "siteWgCompatibleAllClients": "Compatibel met alle WireGuard clients", "siteWgManualConfigurationRequired": "Handmatige configuratie vereist", "userErrorNotAdminOrOwner": "Gebruiker is geen beheerder of eigenaar", "pangolinSettings": "Instellingen - Pangolin", "accessRoleYour": "Jouw rol:", "accessRoleSelect2": "Selecteer rollen", "accessUserSelect": "Gebruikers selecteren", "otpEmailEnter": "Voer e-mailadres in", "otpEmailEnterDescription": "Druk op enter om een e-mail toe te voegen na het typen in het invoerveld.", "otpEmailErrorInvalid": "Ongeldig e-mailadres. Wildcard (*) moet het hele lokale deel zijn.", "otpEmailSmtpRequired": "SMTP vereist", "otpEmailSmtpRequiredDescription": "SMTP moet ingeschakeld zijn op de server om eenmalige wachtwoordauthenticatie te gebruiken.", "otpEmailTitle": "Eenmalige wachtwoorden", "otpEmailTitleDescription": "Vereis e-mailgebaseerde authenticatie voor brontoegang", "otpEmailWhitelist": "E-mail whitelist", "otpEmailWhitelistList": "Toegestane e-mails", "otpEmailWhitelistListDescription": "Alleen gebruikers met deze e-mailadressen hebben toegang tot dit document. Ze zullen worden gevraagd om een eenmalig wachtwoord in te voeren dat naar hun e-mail is verzonden. Wildcards (*@example.com) kunnen worden gebruikt om elk e-mailadres van een domein toe te staan.", "otpEmailWhitelistSave": "Whitelist opslaan", "passwordAdd": "Wachtwoord toevoegen", "passwordRemove": "Wachtwoord verwijderen", "pincodeAdd": "PIN-code toevoegen", "pincodeRemove": "PIN-code verwijderen", "resourceAuthMethods": "Authenticatie methoden", "resourceAuthMethodsDescriptions": "Sta toegang tot de bron toe via extra autorisatiemethoden", "resourceAuthSettingsSave": "Succesvol opgeslagen", "resourceAuthSettingsSaveDescription": "Verificatie-instellingen zijn opgeslagen", "resourceErrorAuthFetch": "Gegevens ophalen mislukt", "resourceErrorAuthFetchDescription": "Er is een fout opgetreden bij het ophalen van de gegevens", "resourceErrorPasswordRemove": "Fout bij verwijderen resource wachtwoord", "resourceErrorPasswordRemoveDescription": "Er is een fout opgetreden tijdens het verwijderen van het bronwachtwoord", "resourceErrorPasswordSetup": "Fout bij instellen resource wachtwoord", "resourceErrorPasswordSetupDescription": "Er is een fout opgetreden bij het instellen van het wachtwoord bron", "resourceErrorPincodeRemove": "Fout bij verwijderen resource pincode", "resourceErrorPincodeRemoveDescription": "Er is een fout opgetreden tijdens het verwijderen van de bronpincode", "resourceErrorPincodeSetup": "Fout bij instellen resource PIN code", "resourceErrorPincodeSetupDescription": "Er is een fout opgetreden bij het instellen van de PIN-code van de bron", "resourceErrorUsersRolesSave": "Kan rollen niet instellen", "resourceErrorUsersRolesSaveDescription": "Er is een fout opgetreden tijdens het instellen van de rollen", "resourceErrorWhitelistSave": "Kan whitelist niet opslaan", "resourceErrorWhitelistSaveDescription": "Er is een fout opgetreden tijdens het opslaan van de whitelist", "resourcePasswordSubmit": "Wachtwoordbescherming inschakelen", "resourcePasswordProtection": "Wachtwoordbescherming {status}", "resourcePasswordRemove": "Wachtwoord document verwijderd", "resourcePasswordRemoveDescription": "Het wachtwoord van de resource is met succes verwijderd", "resourcePasswordSetup": "Wachtwoord document ingesteld", "resourcePasswordSetupDescription": "Het wachtwoord voor de bron is succesvol ingesteld", "resourcePasswordSetupTitle": "Wachtwoord instellen", "resourcePasswordSetupTitleDescription": "Stel een wachtwoord in om deze bron te beschermen", "resourcePincode": "Pincode", "resourcePincodeSubmit": "PIN-Code bescherming inschakelen", "resourcePincodeProtection": "PIN Code bescherming {status}", "resourcePincodeRemove": "Pijncode van resource verwijderd", "resourcePincodeRemoveDescription": "Het wachtwoord van de resource is met succes verwijderd", "resourcePincodeSetup": "PIN-code voor hulpbron ingesteld", "resourcePincodeSetupDescription": "De bronpincode is succesvol ingesteld", "resourcePincodeSetupTitle": "Pincode instellen", "resourcePincodeSetupTitleDescription": "Stel een pincode in om deze hulpbron te beschermen", "resourceRoleDescription": "Beheerders hebben altijd toegang tot deze bron.", "resourceUsersRoles": "Toegang Bediening", "resourceUsersRolesDescription": "Configureer welke gebruikers en rollen deze pagina kunnen bezoeken", "resourceUsersRolesSubmit": "Bewaar Toegangsbesturing", "resourceWhitelistSave": "Succesvol opgeslagen", "resourceWhitelistSaveDescription": "Whitelist instellingen zijn opgeslagen", "ssoUse": "Gebruik Platform SSO", "ssoUseDescription": "Bestaande gebruikers hoeven slechts eenmaal in te loggen voor alle bronnen die dit ingeschakeld hebben.", "proxyErrorInvalidPort": "Ongeldig poortnummer", "subdomainErrorInvalid": "Ongeldig subdomein", "domainErrorFetch": "Fout bij ophalen domeinen", "domainErrorFetchDescription": "Er is een fout opgetreden bij het ophalen van de domeinen", "resourceErrorUpdate": "Bijwerken van resource mislukt", "resourceErrorUpdateDescription": "Er is een fout opgetreden tijdens het bijwerken van het document", "resourceUpdated": "Bron bijgewerkt", "resourceUpdatedDescription": "Het document is met succes bijgewerkt", "resourceErrorTransfer": "Mislukt om resource over te dragen", "resourceErrorTransferDescription": "Er is een fout opgetreden tijdens het overzetten van het document", "resourceTransferred": "Bron overgedragen", "resourceTransferredDescription": "De bron is met succes overgedragen.", "resourceErrorToggle": "Bron wisselen mislukt", "resourceErrorToggleDescription": "Er is een fout opgetreden tijdens het bijwerken van het document", "resourceVisibilityTitle": "Zichtbaarheid", "resourceVisibilityTitleDescription": "Zichtbaarheid van bestanden volledig in- of uitschakelen", "resourceGeneral": "Algemene instellingen", "resourceGeneralDescription": "Configureer de algemene instellingen voor deze bron", "resourceEnable": "Resource inschakelen", "resourceTransfer": "Bronnen overdragen", "resourceTransferDescription": "Verplaats dit document naar een andere site", "resourceTransferSubmit": "Bronnen overdragen", "siteDestination": "Bestemming site", "searchSites": "Sites zoeken", "countries": "Landen", "accessRoleCreate": "Rol aanmaken", "accessRoleCreateDescription": "Maak een nieuwe rol aan om gebruikers te groeperen en hun rechten te beheren.", "accessRoleEdit": "Rol bewerken", "accessRoleEditDescription": "Bewerk rol informatie.", "accessRoleCreateSubmit": "Rol aanmaken", "accessRoleCreated": "Rol aangemaakt", "accessRoleCreatedDescription": "De rol is succesvol aangemaakt.", "accessRoleErrorCreate": "Rol aanmaken mislukt", "accessRoleErrorCreateDescription": "Fout opgetreden tijdens het aanmaken van de rol.", "accessRoleUpdateSubmit": "Rol bijwerken", "accessRoleUpdated": "Rol bijgewerkt", "accessRoleUpdatedDescription": "De rol is succesvol bijgewerkt.", "accessApprovalUpdated": "Afgewerkt met goedkeuring", "accessApprovalApprovedDescription": "Stel het goedkeuringsverzoek in op goedkeuring.", "accessApprovalDeniedDescription": "Stel de beslissing over het goedkeuringsverzoek in als geweigerd.", "accessRoleErrorUpdate": "Bijwerken van rol mislukt", "accessRoleErrorUpdateDescription": "Fout opgetreden tijdens het bijwerken van de rol.", "accessApprovalErrorUpdate": "Kan goedkeuring niet verwerken", "accessApprovalErrorUpdateDescription": "Er is een fout opgetreden bij het verwerken van de goedkeuring.", "accessRoleErrorNewRequired": "Nieuwe rol is vereist", "accessRoleErrorRemove": "Rol verwijderen mislukt", "accessRoleErrorRemoveDescription": "Er is een fout opgetreden tijdens het verwijderen van de rol.", "accessRoleName": "Rol naam", "accessRoleQuestionRemove": "Je staat op het punt de `{name}` rol te verwijderen. Je kunt deze actie niet ongedaan maken.", "accessRoleRemove": "Rol verwijderen", "accessRoleRemoveDescription": "Verwijder een rol van de organisatie", "accessRoleRemoveSubmit": "Rol verwijderen", "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", "licenseTierProfessional": "Professionele licentie", "licenseTierEnterprise": "Enterprise Licentie", "licenseTierPersonal": "Persoonlijke licentie", "licensed": "Gelicentieerd", "yes": "ja", "no": "Neen", "sitesAdditional": "Extra sites", "licenseKeys": "Licentie Sleutels", "sitestCountDecrease": "Verlaag het aantal sites", "sitestCountIncrease": "Toename van site vergroten", "idpManage": "Identiteitsaanbieders beheren", "idpManageDescription": "Identiteitsaanbieders in het systeem bekijken en beheren", "idpGlobalModeBanner": "Identiteitsaanbieders (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld tussen alle organisaties). Beheer globale IdPs in het beheerderspaneel. Om IdPs per organisatie in te schakelen, bewerk de server configuratie en zet IdP modus op org. Zie de documenten. Als je globale IdPs wilt blijven gebruiken en dit uit de organisatie-instellingen wilt laten verdwijnen, zet dan expliciet de modus naar globaal in de config.", "idpGlobalModeBannerUpgradeRequired": "Identity providers (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld in alle organisaties) Beheer globale IdPs in het beheerderspaneel. Om identiteitsproviders per organisatie te gebruiken, moet u upgraden naar de Enterprise editie.", "idpGlobalModeBannerLicenseRequired": "Identity providers (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld in alle organisaties) Beheer globale IdPs in het beheerderspaneel. Om identiteitsaanbieders per organisatie te gebruiken, is een Enterprise-licentie vereist.", "idpDeletedDescription": "Identity provider succesvol verwijderd", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Weet u zeker dat u de identiteitsprovider permanent wilt verwijderen?", "idpMessageRemove": "Dit zal de identiteitsprovider en alle bijbehorende configuraties verwijderen. Gebruikers die via deze provider authenticeren, kunnen niet langer inloggen.", "idpMessageConfirm": "Om dit te bevestigen, typt u de naam van onderstaande identiteitsprovider.", "idpConfirmDelete": "Bevestig verwijderen identiteit provider", "idpDelete": "Identiteit provider verwijderen", "idp": "Identiteitsproviders", "idpSearch": "Identiteitsproviders zoeken...", "idpAdd": "Identiteit provider toevoegen", "idpClientIdRequired": "Client ID is vereist.", "idpClientSecretRequired": "Client geheim is vereist.", "idpErrorAuthUrlInvalid": "Authenticatie URL moet een geldige URL zijn.", "idpErrorTokenUrlInvalid": "Token URL moet een geldige URL zijn.", "idpPathRequired": "ID-pad is vereist.", "idpScopeRequired": "Toepassingsgebieden zijn vereist.", "idpOidcDescription": "Een OpenID Connect identiteitsprovider configureren", "idpCreatedDescription": "Identiteitsprovider succesvol aangemaakt", "idpCreate": "Identiteitsprovider aanmaken", "idpCreateDescription": "Een nieuwe identiteitsprovider voor authenticatie configureren", "idpSeeAll": "Zie alle Identiteitsproviders", "idpSettingsDescription": "Configureer de basisinformatie voor uw identiteitsprovider", "idpDisplayName": "Een weergavenaam voor deze identiteitsprovider", "idpAutoProvisionUsers": "Auto Provisie Gebruikers", "idpAutoProvisionUsersDescription": "Wanneer ingeschakeld, worden gebruikers automatisch in het systeem aangemaakt wanneer ze de eerste keer inloggen met de mogelijkheid om gebruikers toe te wijzen aan rollen en organisaties.", "licenseBadge": "EE", "idpType": "Type provider", "idpTypeDescription": "Selecteer het type identiteitsprovider dat u wilt configureren", "idpOidcConfigure": "OAuth2/OIDC configuratie", "idpOidcConfigureDescription": "Configureer de eindpunten van de OAuth2/OIDC provider en referenties", "idpClientId": "Client ID", "idpClientIdDescription": "De OAuth2-client-ID van de identiteitsaanbieder", "idpClientSecret": "Client Secret", "idpClientSecretDescription": "Het OAuth2-clientgeheim van de identiteitsprovider", "idpAuthUrl": "URL autorisatie", "idpAuthUrlDescription": "De URL voor autorisatie OAuth2", "idpTokenUrl": "URL token", "idpTokenUrlDescription": "De URL van het OAuth2 token eindpunt", "idpOidcConfigureAlert": "Belangrijke informatie", "idpOidcConfigureAlertDescription": "Na het aanmaken van de identity provider moet u de callback URL configureren in de instellingen van de identity provider. De callback URL zal worden opgegeven na het succesvol aanmaken.", "idpToken": "Token configuratie", "idpTokenDescription": "Stel in hoe gebruikersgegevens uit het ID token uit te pakken", "idpJmespathAbout": "Over JMESPath", "idpJmespathAboutDescription": "De onderstaande paden gebruiken JMESPath syntaxis om waarden van de ID-token te extraheren.", "idpJmespathAboutDescriptionLink": "Meer informatie over JMESPath", "idpJmespathLabel": "ID pad", "idpJmespathLabelDescription": "Het pad naar het gebruiker-id in het ID-token", "idpJmespathEmailPathOptional": "E-mail pad (optioneel)", "idpJmespathEmailPathOptionalDescription": "Het pad naar het e-mailadres van de gebruiker in het ID-token", "idpJmespathNamePathOptional": "Naam pad (optioneel)", "idpJmespathNamePathOptionalDescription": "Het pad naar de naam van de gebruiker in de ID-token", "idpOidcConfigureScopes": "Toepassingsgebieden", "idpOidcConfigureScopesDescription": "Te vragen ruimtescheiden lijst van OAuth2 toepassingsgebieden", "idpSubmit": "Identity Provider aanmaken", "orgPolicies": "Organisatie beleid", "idpSettings": "{idpName} instellingen", "idpCreateSettingsDescription": "Configureer de instellingen voor de identiteitsprovider", "roleMapping": "Rol Toewijzing", "orgMapping": "Organisatie toewijzing", "orgPoliciesSearch": "Zoek het organisatiebeleid...", "orgPoliciesAdd": "Organisatiebeleid toevoegen", "orgRequired": "Organisatie is vereist", "error": "Foutmelding", "success": "Geslaagd", "orgPolicyAddedDescription": "Beleid succesvol toegevoegd", "orgPolicyUpdatedDescription": "Beleid succesvol bijgewerkt", "orgPolicyDeletedDescription": "Beleid succesvol verwijderd", "defaultMappingsUpdatedDescription": "Standaard toewijzingen met succes bijgewerkt", "orgPoliciesAbout": "Over organisatiebeleid", "orgPoliciesAboutDescription": "Organisatiebeleid wordt gebruikt om toegang tot organisaties te beheren op basis van de gebruiker-ID-token. U kunt JMESPath expressies opgeven om rol en organisatie informatie van de ID-token te extraheren.", "orgPoliciesAboutDescriptionLink": "Zie documentatie, voor meer informatie.", "defaultMappingsOptional": "Standaard toewijzingen (optioneel)", "defaultMappingsOptionalDescription": "De standaard toewijzingen worden gebruikt wanneer er geen organisatiebeleid is gedefinieerd voor een organisatie. Je kunt de standaard rol en organisatietoewijzingen opgeven waar je naar terug kunt vallen.", "defaultMappingsRole": "Standaard Rol Toewijzing", "defaultMappingsRoleDescription": "Het resultaat van deze uitdrukking moet de rolnaam zoals gedefinieerd in de organisatie als tekenreeks teruggeven.", "defaultMappingsOrg": "Standaard organisatie mapping", "defaultMappingsOrgDescription": "Deze expressie moet de org-ID teruggeven of waar om de gebruiker toegang te geven tot de organisatie.", "defaultMappingsSubmit": "Standaard toewijzingen opslaan", "orgPoliciesEdit": "Organisatie beleid bewerken", "org": "Organisatie", "orgSelect": "Selecteer organisatie", "orgSearch": "Zoek in org", "orgNotFound": "Geen org gevonden.", "roleMappingPathOptional": "Rol toewijzing pad (optioneel)", "orgMappingPathOptional": "Organisatie mapping pad (optioneel)", "orgPolicyUpdate": "Update beleid", "orgPolicyAdd": "Beleid toevoegen", "orgPolicyConfig": "Toegang voor een organisatie configureren", "idpUpdatedDescription": "Identity provider succesvol bijgewerkt", "redirectUrl": "Omleidings URL", "orgIdpRedirectUrls": "URL's omleiden", "redirectUrlAbout": "Over omleidings-URL", "redirectUrlAboutDescription": "Dit is de URL waarnaar gebruikers worden doorverwezen na verificatie. U moet deze URL configureren in de instellingen van de identiteitsprovider.", "pangolinAuth": "Authenticatie - Pangolin", "verificationCodeLengthRequirements": "Je verificatiecode moet 8 tekens bevatten.", "errorOccurred": "Er is een fout opgetreden", "emailErrorVerify": "E-mail verifiëren is mislukt:", "emailVerified": "E-mail met succes geverifieerd! Doorsturen naar u...", "verificationCodeErrorResend": "Fout bij het opnieuw verzenden van de verificatiecode:", "verificationCodeResend": "Verificatiecode opnieuw verzonden", "verificationCodeResendDescription": "We hebben een verificatiecode opnieuw naar je e-mailadres gestuurd. Controleer je inbox.", "emailVerify": "Bevestig e-mailadres", "emailVerifyDescription": "Voer de verificatiecode in die naar uw e-mailadres is verzonden.", "verificationCode": "Verificatie Code", "verificationCodeEmailSent": "We hebben een verificatiecode naar je e-mailadres gestuurd.", "submit": "Bevestigen", "emailVerifyResendProgress": "Opnieuw verzenden...", "emailVerifyResend": "Geen code ontvangen? Klik hier om opnieuw te verzenden", "passwordNotMatch": "Wachtwoorden komen niet overeen", "signupError": "Er is een fout opgetreden tijdens het aanmelden", "pangolinLogoAlt": "Pangolin logo", "inviteAlready": "Het lijkt erop dat je bent uitgenodigd!", "inviteAlreadyDescription": "Om de uitnodiging te accepteren, moet je inloggen of een account aanmaken.", "signupQuestion": "Heeft u al een account?", "login": "Log in", "resourceNotFound": "Bron niet gevonden", "resourceNotFoundDescription": "De bron die u probeert te benaderen bestaat niet.", "pincodeRequirementsLength": "Pincode moet precies 6 cijfers zijn", "pincodeRequirementsChars": "Pincode mag alleen cijfers bevatten", "passwordRequirementsLength": "Wachtwoord moet ten minste 1 teken lang zijn", "passwordRequirementsTitle": "Wachtwoordvereisten:", "passwordRequirementLength": "Minstens 8 tekens lang", "passwordRequirementUppercase": "Minstens één hoofdletter", "passwordRequirementLowercase": "Minstens één kleine letter", "passwordRequirementNumber": "Minstens één cijfer", "passwordRequirementSpecial": "Minstens één speciaal teken", "passwordRequirementsMet": "✓ Wachtwoord voldoet aan alle vereisten", "passwordStrength": "Wachtwoord sterkte", "passwordStrengthWeak": "Zwak", "passwordStrengthMedium": "Gemiddeld", "passwordStrengthStrong": "Sterk", "passwordRequirements": "Vereisten:", "passwordRequirementLengthText": "8+ tekens", "passwordRequirementUppercaseText": "Hoofdletter (A-Z)", "passwordRequirementLowercaseText": "Kleine letter (a-z)", "passwordRequirementNumberText": "Cijfer (0-9)", "passwordRequirementSpecialText": "Speciaal teken (!@#$%...)", "passwordsDoNotMatch": "Wachtwoorden komen niet overeen", "otpEmailRequirementsLength": "OTP moet minstens 1 teken lang zijn", "otpEmailSent": "OTP verzonden", "otpEmailSentDescription": "Een OTP is naar uw e-mail verzonden", "otpEmailErrorAuthenticate": "Authenticatie met e-mail mislukt", "pincodeErrorAuthenticate": "Authenticatie met pincode mislukt", "passwordErrorAuthenticate": "Authenticatie met wachtwoord mislukt", "poweredBy": "Mogelijk gemaakt door", "authenticationRequired": "Authenticatie vereist", "authenticationMethodChoose": "Kies uw voorkeursmethode voor toegang tot {name}", "authenticationRequest": "U moet zich aanmelden om {name} te kunnen gebruiken", "user": "Gebruiker", "pincodeInput": "6-cijferige PIN-Code", "pincodeSubmit": "Inloggen met PIN", "passwordSubmit": "Log in met wachtwoord", "otpEmailDescription": "Een eenmalige code zal worden verzonden naar deze e-mail.", "otpEmailSend": "Verstuur éénmalige code", "otpEmail": "Eenmalig wachtwoord (OTP)", "otpEmailSubmit": "OTP inzenden", "backToEmail": "Terug naar E-mail", "noSupportKey": "Server draait zonder een supporter sleutel. Overweeg het project te ondersteunen!", "accessDenied": "Toegang geweigerd", "accessDeniedDescription": "U heeft geen toegang tot deze resource. Als dit een vergissing is, neem dan contact op met de beheerder.", "accessTokenError": "Fout bij controleren toegangstoken", "accessGranted": "Toegang verleend", "accessUrlInvalid": "URL ongeldig", "accessGrantedDescription": "Er is u toegang verleend tot deze resource. U wordt doorgestuurd...", "accessUrlInvalidDescription": "Deze URL voor gedeelde toegang is ongeldig. Neem contact op met de documenteigenaar voor een nieuwe URL.", "tokenInvalid": "Ongeldig token", "pincodeInvalid": "Ongeldige code", "passwordErrorRequestReset": "Verzoek om resetten mislukt:", "passwordErrorReset": "Wachtwoord opnieuw instellen mislukt:", "passwordResetSuccess": "Wachtwoord succesvol gereset! Terug naar inloggen...", "passwordReset": "Wachtwoord opnieuw instellen", "passwordResetDescription": "Volg de stappen om uw wachtwoord opnieuw in te stellen", "passwordResetSent": "We sturen een wachtwoord reset code naar dit e-mailadres.", "passwordResetCode": "Resetcode", "passwordResetCodeDescription": "Controleer je e-mail voor de reset code.", "generatePasswordResetCode": "Herstelcode voor wachtwoord genereren", "passwordResetCodeGenerated": "Wachtwoord reset code gegenereerd", "passwordResetCodeGeneratedDescription": "Deel deze code met de gebruiker. Ze kunnen deze gebruiken om hun wachtwoord te resetten.", "passwordResetUrl": "Reset URL", "passwordNew": "Nieuw wachtwoord", "passwordNewConfirm": "Bevestig nieuw wachtwoord", "changePassword": "Wachtwoord wijzigen", "changePasswordDescription": "Uw wachtwoord bijwerken", "oldPassword": "Huidig wachtwoord", "newPassword": "Nieuw wachtwoord", "confirmNewPassword": "Bevestig nieuw wachtwoord", "changePasswordError": "Wachtwoord wijzigen mislukt", "changePasswordErrorDescription": "Er is een fout opgetreden tijdens het wijzigen van uw wachtwoord", "changePasswordSuccess": "Wachtwoord succesvol gewijzigd", "changePasswordSuccessDescription": "Uw wachtwoord is met succes bijgewerkt", "passwordExpiryRequired": "Wachtwoord vervalt verplicht", "passwordExpiryDescription": "Deze organisatie vereist dat u om de {maxDays} dagen uw wachtwoord wijzigt.", "changePasswordNow": "Wijzig wachtwoord nu", "pincodeAuth": "Authenticatiecode", "pincodeSubmit2": "Code indienen", "passwordResetSubmit": "Opnieuw instellen aanvragen", "passwordResetAlreadyHaveCode": "Code invoeren", "passwordResetSmtpRequired": "Neem contact op met uw beheerder", "passwordResetSmtpRequiredDescription": "Er is een wachtwoord reset code nodig om uw wachtwoord opnieuw in te stellen. Neem contact op met uw beheerder voor hulp.", "passwordBack": "Terug naar wachtwoord", "loginBack": "Ga terug naar de hoofdinlogpagina", "signup": "Registreer nu", "loginStart": "Log in om te beginnen", "idpOidcTokenValidating": "Valideer OIDC-token", "idpOidcTokenResponse": "Valideer OIDC token antwoord", "idpErrorOidcTokenValidating": "Fout bij valideren OIDC-token", "idpConnectingTo": "Verbinden met {name}", "idpConnectingToDescription": "Uw identiteit bevestigen", "idpConnectingToProcess": "Verbinden...", "idpConnectingToFinished": "Verbonden", "idpErrorConnectingTo": "Er was een probleem bij het verbinden met {name}. Neem contact op met uw beheerder.", "idpErrorNotFound": "IdP niet gevonden", "inviteInvalid": "Ongeldige uitnodiging", "inviteInvalidDescription": "Uitnodigingslink is ongeldig.", "inviteErrorWrongUser": "Uitnodiging is niet voor deze gebruiker", "inviteErrorUserNotExists": "Gebruiker bestaat niet. Maak eerst een account aan.", "inviteErrorLoginRequired": "Je moet ingelogd zijn om een uitnodiging te accepteren", "inviteErrorExpired": "De uitnodiging is mogelijk verlopen", "inviteErrorRevoked": "De uitnodiging is mogelijk ingetrokken", "inviteErrorTypo": "Er kan een typefout zijn in de uitnodigingslink", "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.", "overview": "Overzicht.", "home": "Startpagina", "settings": "Instellingen", "usersAll": "Alle gebruikers", "license": "Licentie", "pangolinDashboard": "Dashboard - Pangolin", "noResults": "Geen resultaten gevonden.", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "Ingevoerde tags", "tagsEnteredDescription": "Dit zijn de tags die u hebt ingevoerd.", "tagsWarnCannotBeLessThanZero": "maxTags en minTags kunnen niet minder dan 0 zijn", "tagsWarnNotAllowedAutocompleteOptions": "Tag niet toegestaan als per autocomplete opties", "tagsWarnInvalid": "Ongeldige tag per validateTag", "tagWarnTooShort": "Tag {tagText} is te kort", "tagWarnTooLong": "Tag {tagText} is te lang", "tagsWarnReachedMaxNumber": "Het maximum aantal toegestane tags bereikt", "tagWarnDuplicate": "Dubbele tag {tagText} niet toegevoegd", "supportKeyInvalid": "Ongeldige sleutel", "supportKeyInvalidDescription": "Je supporter sleutel is ongeldig.", "supportKeyValid": "Geldige sleutel", "supportKeyValidDescription": "Uw supporter sleutel is gevalideerd. Bedankt voor uw steun!", "supportKeyErrorValidationDescription": "Niet gelukt om de supportersleutel te valideren.", "supportKey": "Ondersteun ontwikkeling en Adopt een Pangolin!", "supportKeyDescription": "Koop een supporter sleutel om ons te helpen Pangolin voor de gemeenschap te blijven ontwikkelen. Je bijdrage geeft ons meer tijd om nieuwe functies te behouden en toe te voegen aan de applicatie voor iedereen. We zullen dit nooit gebruiken voor paywall-functies. Dit staat los van elke commerciële editie.", "supportKeyPet": "U zult ook uw eigen huisdier Pangolin moeten adopteren en ontmoeten!", "supportKeyPurchase": "Betalingen worden verwerkt via GitHub. Daarna kunt u de sleutel ophalen op", "supportKeyPurchaseLink": "onze website", "supportKeyPurchase2": "en verzilver het hier.", "supportKeyLearnMore": "Meer informatie.", "supportKeyOptions": "Selecteer de optie die het beste bij u past.", "supportKetOptionFull": "Volledige supporter", "forWholeServer": "Voor de hele server", "lifetimePurchase": "Levenslange aankoop", "supporterStatus": "Status supporter", "buy": "Kopen", "supportKeyOptionLimited": "Beperkte Supporter", "forFiveUsers": "Voor 5 of minder gebruikers", "supportKeyRedeem": "Supportersleutel inwisselen", "supportKeyHideSevenDays": "Verbergen voor 7 dagen", "supportKeyEnter": "Voer de supportersleutel in", "supportKeyEnterDescription": "Ontmoet je eigen huisdier Pangolin!", "githubUsername": "GitHub-gebruikersnaam", "supportKeyInput": "Supporter Sleutel", "supportKeyBuy": "Koop supportersleutel", "logoutError": "Fout bij uitloggen", "signingAs": "Ingelogd als", "serverAdmin": "Server beheer", "managedSelfhosted": "Beheerde Self-Hosted", "otpEnable": "Twee-factor inschakelen", "otpDisable": "Tweestapsverificatie uitschakelen", "logout": "Log uit", "licenseTierProfessionalRequired": "Professionele editie vereist", "licenseTierProfessionalRequiredDescription": "Deze functie is alleen beschikbaar in de Professional Edition.", "actionGetOrg": "Krijg Organisatie", "updateOrgUser": "Org gebruiker bijwerken", "createOrgUser": "Org gebruiker aanmaken", "actionUpdateOrg": "Organisatie bijwerken", "actionRemoveInvitation": "Verwijder uitnodiging", "actionUpdateUser": "Gebruiker bijwerken", "actionGetUser": "Gebruiker ophalen", "actionGetOrgUser": "Krijg organisatie-gebruiker", "actionListOrgDomains": "Lijst organisatie domeinen", "actionGetDomain": "Domein verkrijgen", "actionCreateOrgDomain": "Domein aanmaken", "actionUpdateOrgDomain": "Domein bijwerken", "actionDeleteOrgDomain": "Domein verwijderen", "actionGetDNSRecords": "Krijg DNS Records", "actionRestartOrgDomain": "Domein opnieuw starten", "actionCreateSite": "Site aanmaken", "actionDeleteSite": "Site verwijderen", "actionGetSite": "Site ophalen", "actionListSites": "Sites weergeven", "actionApplyBlueprint": "Blauwdruk toepassen", "actionListBlueprints": "Lijst blauwdrukken", "actionGetBlueprint": "Krijg Blauwdruk", "setupToken": "Instel Token", "setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.", "setupTokenRequired": "Setup-token is vereist", "actionUpdateSite": "Site bijwerken", "actionListSiteRoles": "Toon toegestane sitenollen", "actionCreateResource": "Bron maken", "actionDeleteResource": "Document verwijderen", "actionGetResource": "Bron ophalen", "actionListResource": "Bronnen weergeven", "actionUpdateResource": "Document bijwerken", "actionListResourceUsers": "Lijst van documentgebruikers", "actionSetResourceUsers": "Stel document gebruikers in", "actionSetAllowedResourceRoles": "Toegestane Resource Rollen instellen", "actionListAllowedResourceRoles": "Lijst Toegestane Resource Rollen", "actionSetResourcePassword": "Stel bronwachtwoord in", "actionSetResourcePincode": "Stel Resource Pincode in", "actionSetResourceEmailWhitelist": "Stel Resource e-mail whitelist in", "actionGetResourceEmailWhitelist": "Verkrijg Resource E-mail Whitelist", "actionCreateTarget": "Doelwit aanmaken", "actionDeleteTarget": "Verwijder doel", "actionGetTarget": "Verkrijg Doel", "actionListTargets": "Doelstellingen weergeven", "actionUpdateTarget": "Doelwit bijwerken", "actionCreateRole": "Rol aanmaken", "actionDeleteRole": "Verwijder rol", "actionGetRole": "Krijg Rol", "actionListRole": "Toon rollen", "actionUpdateRole": "Rol bijwerken", "actionListAllowedRoleResources": "Lijst toegestane rolbronnen", "actionInviteUser": "Gebruiker uitnodigen", "actionRemoveUser": "Gebruiker verwijderen", "actionListUsers": "Gebruikers weergeven", "actionAddUserRole": "Gebruikersrol toevoegen", "actionGenerateAccessToken": "Genereer Toegangstoken", "actionDeleteAccessToken": "Verwijder toegangstoken", "actionListAccessTokens": "Lijst toegangstokens", "actionCreateResourceRule": "Bronregel aanmaken", "actionDeleteResourceRule": "Verwijder Resource Regel", "actionListResourceRules": "Bron regels weergeven", "actionUpdateResourceRule": "Bronregel bewerken", "actionListOrgs": "Organisaties weergeven", "actionCheckOrgId": "ID controleren", "actionCreateOrg": "Nieuwe organisatie aanmaken", "actionDeleteOrg": "Verwijder organisatie", "actionListApiKeys": "API-sleutels weergeven", "actionListApiKeyActions": "Lijst van API Key Acties", "actionSetApiKeyActions": "Stel API Key Toegestane Acties", "actionCreateApiKey": "API-sleutel aanmaken", "actionDeleteApiKey": "API-sleutel verwijderen", "actionCreateIdp": "IDP aanmaken", "actionUpdateIdp": "IDP bijwerken", "actionDeleteIdp": "Verwijder IDP", "actionListIdps": "Toon IDP", "actionGetIdp": "IDP ophalen", "actionCreateIdpOrg": "Maak IDP Org Policy", "actionDeleteIdpOrg": "Verwijder IDP Org Beleid", "actionListIdpOrgs": "Toon IDP Orgs", "actionUpdateIdpOrg": "IDP-org bijwerken", "actionCreateClient": "Client aanmaken", "actionDeleteClient": "Verwijder klant", "actionArchiveClient": "Archiveer client", "actionUnarchiveClient": "Dearchiveer client", "actionBlockClient": "Blokkeer klant", "actionUnblockClient": "Deblokkeer client", "actionUpdateClient": "Klant bijwerken", "actionListClients": "Lijst klanten", "actionGetClient": "Client ophalen", "actionCreateSiteResource": "Sitebron maken", "actionDeleteSiteResource": "Document verwijderen van site", "actionGetSiteResource": "Bron van site ophalen", "actionListSiteResources": "Bronnen van site weergeven", "actionUpdateSiteResource": "Document bijwerken van site", "actionListInvitations": "Toon uitnodigingen", "actionExportLogs": "Logboeken exporteren", "actionViewLogs": "Logboeken bekijken", "noneSelected": "Niet geselecteerd", "orgNotFound2": "Geen organisaties gevonden.", "searchPlaceholder": "Zoeken...", "emptySearchOptions": "Geen opties gevonden", "create": "Aanmaken", "orgs": "Organisaties", "loginError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.", "loginRequiredForDevice": "Inloggen is vereist voor je apparaat.", "passwordForgot": "Wachtwoord vergeten?", "otpAuth": "Tweestapsverificatie verificatie", "otpAuthDescription": "Voer de code van je authenticator-app of een van je reservekopiecodes voor het eenmalig gebruik in.", "otpAuthSubmit": "Code indienen", "idpContinue": "Of ga verder met", "otpAuthBack": "Terug naar wachtwoord", "navbar": "Navigatiemenu", "navbarDescription": "Hoofd navigatie menu voor de applicatie", "navbarDocsLink": "Documentatie", "otpErrorEnable": "Kan 2FA niet inschakelen", "otpErrorEnableDescription": "Er is een fout opgetreden tijdens het inschakelen van 2FA", "otpSetupCheckCode": "Voer een 6-cijferige code in", "otpSetupCheckCodeRetry": "Ongeldige code. Probeer het opnieuw.", "otpSetup": "Tweestapsverificatie inschakelen", "otpSetupDescription": "Beveilig je account met een extra beveiligingslaag", "otpSetupScanQr": "Scan deze QR-code met je authenticator-app of voer de geheime sleutel handmatig in:", "otpSetupSecretCode": "Authenticatiecode", "otpSetupSuccess": "Tweestapsverificatie ingeschakeld", "otpSetupSuccessStoreBackupCodes": "Uw account is nu veiliger. Vergeet niet uw back-upcodes op te slaan.", "otpErrorDisable": "Kan 2FA niet uitschakelen", "otpErrorDisableDescription": "Er is een fout opgetreden tijdens het uitschakelen van 2FA", "otpRemove": "Tweestapsverificatie uitschakelen", "otpRemoveDescription": "Tweestapsverificatie uitschakelen voor je account", "otpRemoveSuccess": "Tweestapsverificatie uitgeschakeld", "otpRemoveSuccessMessage": "Tweestapsverificatie is uitgeschakeld voor uw account. U kunt dit op elk gewenst moment opnieuw inschakelen.", "otpRemoveSubmit": "2FA uitschakelen", "paginator": "Pagina {current} van {last}", "paginatorToFirst": "Ga naar eerste pagina", "paginatorToPrevious": "Ga naar vorige pagina", "paginatorToNext": "Ga naar de volgende pagina", "paginatorToLast": "Ga naar de laatste pagina", "copyText": "Tekst kopiëren", "copyTextFailed": "Kan tekst niet kopiëren: ", "copyTextClipboard": "Kopiëren naar klembord", "inviteErrorInvalidConfirmation": "Ongeldige bevestiging", "passwordRequired": "Wachtwoord is vereist", "allowAll": "Alles toestaan", "permissionsAllowAll": "Alle machtigingen toestaan", "githubUsernameRequired": "GitHub gebruikersnaam is vereist", "supportKeyRequired": "Supportersleutel is vereist", "passwordRequirementsChars": "Wachtwoord moet ten minste 8 tekens bevatten", "language": "Taal", "verificationCodeRequired": "Code is vereist", "userErrorNoUpdate": "Geen gebruiker om te updaten", "siteErrorNoUpdate": "Geen site om bij te werken", "resourceErrorNoUpdate": "Geen document om bij te werken", "authErrorNoUpdate": "Geen authenticatie informatie om bij te werken", "orgErrorNoUpdate": "Geen org om bij te werken", "orgErrorNoProvided": "Geen org opgegeven", "apiKeysErrorNoUpdate": "Geen API-sleutel om bij te werken", "sidebarOverview": "Overzicht.", "sidebarHome": "Startpagina", "sidebarSites": "Werkruimtes", "sidebarApprovals": "Goedkeuringsverzoeken", "sidebarResources": "Bronnen", "sidebarProxyResources": "Openbaar", "sidebarClientResources": "Privé", "sidebarAccessControl": "Toegangs controle", "sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarTeam": "Team", "sidebarUsers": "Gebruikers", "sidebarAdmin": "Beheerder", "sidebarInvitations": "Uitnodigingen", "sidebarRoles": "Rollen", "sidebarShareableLinks": "Koppelingen", "sidebarApiKeys": "API sleutels", "sidebarSettings": "Instellingen", "sidebarAllUsers": "Alle gebruikers", "sidebarIdentityProviders": "Identiteit aanbieders", "sidebarLicense": "Licentie", "sidebarClients": "Clienten", "sidebarUserDevices": "Gebruiker Apparaten", "sidebarMachineClients": "Machines", "sidebarDomains": "Domeinen", "sidebarGeneral": "Beheren", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blauwdrukken", "sidebarOrganization": "Organisatie", "sidebarManagement": "Beheer", "sidebarBillingAndLicenses": "Facturatie & Licenties", "sidebarLogsAnalytics": "Analyses", "blueprints": "Blauwdrukken", "blueprintsDescription": "Gebruik declaratieve configuraties en bekijk vorige uitvoeringen.", "blueprintAdd": "Blauwdruk toevoegen", "blueprintGoBack": "Bekijk alle Blauwdrukken", "blueprintCreate": "Creëer blauwdruk", "blueprintCreateDescription2": "Volg de onderstaande stappen om een nieuwe blauwdruk te maken en toe te passen", "blueprintDetails": "Blauwdruk Details", "blueprintDetailsDescription": "Bekijk het resultaat van de toegepaste blauwdruk en eventuele fouten", "blueprintInfo": "Blauwdruk Informatie", "message": "bericht", "blueprintContentsDescription": "Definieer de YAML-content die de infrastructuur beschrijft", "blueprintErrorCreateDescription": "Er is een fout opgetreden bij het toepassen van de blauwdruk", "blueprintErrorCreate": "Fout bij maken blauwdruk", "searchBlueprintProgress": "Blauwdrukken zoeken...", "appliedAt": "Toegepast op", "source": "Bron", "contents": "Inhoud", "parsedContents": "Geparseerde inhoud (alleen lezen)", "enableDockerSocket": "Schakel Docker Blauwdruk in", "enableDockerSocketDescription": "Schakel Docker Socket label in voor blauwdruk labels. Pad naar Nieuw.", "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.", "containerName": "naam", "containerImage": "Afbeelding", "containerState": "Provincie", "containerNetworks": "Netwerken", "containerHostnameIp": "Hostnaam/IP", "containerLabels": "Labels", "containerLabelsCount": "{count, plural, one {# label} other {# labels}}", "containerLabelsTitle": "Container labels", "containerLabelEmpty": "", "containerPorts": "Poorten", "containerPortsMore": "+{count} meer", "containerActions": "acties", "select": "Selecteren", "noContainersMatchingFilters": "Geen containers gevonden die overeenkomen met de huidige filters.", "showContainersWithoutPorts": "Toon containers zonder poorten", "showStoppedContainers": "Toon gestopte containers", "noContainersFound": "Geen containers gevonden. Zorg ervoor dat Docker containers draaien.", "searchContainersPlaceholder": "Zoek tussen {count} containers...", "searchResultsCount": "{count, plural, one {# resultaat} other {# resultaten}}", "filters": "Filters", "filterOptions": "Filter opties", "filterPorts": "Poorten", "filterStopped": "Gestopt", "clearAllFilters": "Alle filters wissen", "columns": "Kolommen", "toggleColumns": "Kolommen omschakelen", "refreshContainersList": "Vernieuw containers lijst", "searching": "Zoeken...", "noContainersFoundMatching": "Geen containers gevonden die overeenkomen met \"{filter}\".", "light": "licht", "dark": "donker", "system": "systeem", "theme": "Thema", "subnetRequired": "Subnet is vereist", "initialSetupTitle": "Initiële serverconfiguratie", "initialSetupDescription": "Maak het eerste serverbeheeraccount aan. Er kan slechts één serverbeheerder bestaan. U kunt deze inloggegevens later altijd wijzigen.", "createAdminAccount": "Maak een beheeraccount aan", "setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.", "certificateStatus": "Certificaatstatus", "loading": "Bezig met laden", "loadingAnalytics": "Laden van Analytics", "restart": "Herstarten", "domains": "Domeinen", "domainsDescription": "Maak en beheer domeinen die beschikbaar zijn in de organisatie", "domainsSearch": "Zoek domeinen...", "domainAdd": "Domein toevoegen", "domainAddDescription": "Registreer een nieuw domein met de organisatie", "domainCreate": "Domein aanmaken", "domainCreatedDescription": "Domein succesvol aangemaakt", "domainDeletedDescription": "Domein succesvol verwijderd", "domainQuestionRemove": "Weet u zeker dat u dit domein wilt verwijderen?", "domainMessageRemove": "Eenmaal verwijderd, wordt het domein niet langer gekoppeld aan de organisatie.", "domainConfirmDelete": "Bevestig verwijdering van domein", "domainDelete": "Domein verwijderen", "domain": "Domein", "selectDomainTypeNsName": "Domeindelegatie (NS)", "selectDomainTypeNsDescription": "Dit domein en al zijn subdomeinen. Gebruik dit wanneer je een volledige domeinzone wilt beheersen.", "selectDomainTypeCnameName": "Enkel domein (CNAME)", "selectDomainTypeCnameDescription": "Alleen dit specifieke domein. Gebruik dit voor individuele subdomeinen of specifieke domeinvermeldingen.", "selectDomainTypeWildcardName": "Wildcard Domein", "selectDomainTypeWildcardDescription": "Dit domein en zijn subdomeinen.", "domainDelegation": "Enkel domein", "selectType": "Selecteer een type", "actions": "acties", "refresh": "Vernieuwen", "refreshError": "Het vernieuwen van gegevens is mislukt", "verified": "Gecontroleerd", "pending": "In afwachting", "pendingApproval": "Wachten op goedkeuring", "sidebarBilling": "Facturering", "billing": "Facturering", "orgBillingDescription": "Beheer factureringsinformatie en abonnementen", "github": "GitHub", "pangolinHosted": "Pangolin gehost", "fossorial": "Fossorial", "completeAccountSetup": "Voltooi accountinstelling", "completeAccountSetupDescription": "Stel je wachtwoord in om te beginnen", "accountSetupSent": "We sturen een accountinstellingscode naar dit e-mailadres.", "accountSetupCode": "Instellingscode", "accountSetupCodeDescription": "Controleer je e-mail voor de instellingscode.", "passwordCreate": "Wachtwoord aanmaken", "passwordCreateConfirm": "Bevestig wachtwoord", "accountSetupSubmit": "Instellingscode verzenden", "completeSetup": "Voltooi instellen", "accountSetupSuccess": "Accountinstelling voltooid! Welkom bij Pangolin!", "documentation": "Documentatie", "saveAllSettings": "Alle instellingen opslaan", "saveResourceTargets": "Doelstellingen opslaan", "saveResourceHttp": "Proxyinstellingen opslaan", "saveProxyProtocol": "Proxy-protocolinstellingen opslaan", "settingsUpdated": "Instellingen bijgewerkt", "settingsUpdatedDescription": "Instellingen succesvol bijgewerkt", "settingsErrorUpdate": "Bijwerken van instellingen mislukt", "settingsErrorUpdateDescription": "Er is een fout opgetreden bij het bijwerken van instellingen", "sidebarCollapse": "Inklappen", "sidebarExpand": "Uitklappen", "productUpdateMoreInfo": "Nog {noOfUpdates} updates", "productUpdateInfo": "{noOfUpdates} updates", "productUpdateWhatsNew": "Wat is nieuw", "productUpdateTitle": "Update Producten", "productUpdateEmpty": "Geen updates", "dismissAll": "Alles afwijzen", "pangolinUpdateAvailable": "Update beschikbaar", "pangolinUpdateAvailableInfo": "Versie {version} is klaar om te installeren", "pangolinUpdateAvailableReleaseNotes": "Uitgaveopmerkingen bekijken", "newtUpdateAvailable": "Update beschikbaar", "newtUpdateAvailableInfo": "Er is een nieuwe versie van Newt beschikbaar. Update naar de nieuwste versie voor de beste ervaring.", "domainPickerEnterDomain": "Domein", "domainPickerPlaceholder": "mijnapp.voorbeeld.nl", "domainPickerDescription": "Voer de volledige domein van de bron in om beschikbare opties te zien.", "domainPickerDescriptionSaas": "Voer een volledig domein, subdomein of gewoon een naam in om beschikbare opties te zien", "domainPickerTabAll": "Alles", "domainPickerTabOrganization": "Organisatie", "domainPickerTabProvided": "Aangeboden", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Beschikbaarheid controleren...", "domainPickerNoMatchingDomains": "Geen overeenkomende domeinen gevonden. Probeer een ander domein of controleer de domeininstellingen van de organisatie.", "domainPickerOrganizationDomains": "Organisatiedomeinen", "domainPickerProvidedDomains": "Aangeboden domeinen", "domainPickerSubdomain": "Subdomein: {subdomain}", "domainPickerNamespace": "Naamruimte: {namespace}", "domainPickerShowMore": "Meer weergeven", "regionSelectorTitle": "Selecteer Regio", "regionSelectorInfo": "Het selecteren van een regio helpt ons om betere prestaties te leveren voor uw locatie. U hoeft niet in dezelfde regio als uw server te zijn.", "regionSelectorPlaceholder": "Kies een regio", "regionSelectorComingSoon": "Komt binnenkort", "billingLoadingSubscription": "Abonnement laden...", "billingFreeTier": "Gratis Niveau", "billingWarningOverLimit": "Waarschuwing: U hebt een of meer gebruikslimieten overschreden. Uw sites maken geen verbinding totdat u uw abonnement aanpast of uw gebruik aanpast.", "billingUsageLimitsOverview": "Overzicht gebruikslimieten", "billingMonitorUsage": "Houd uw gebruik in de gaten ten opzichte van de ingestelde limieten. Als u verhoogde limieten nodig heeft, neem dan contact met ons op support@pangolin.net.", "billingDataUsage": "Gegevensgebruik", "billingSites": "Sites", "billingUsers": "Gebruikers", "billingDomains": "Domeinen", "billingOrganizations": "Ordenen", "billingRemoteExitNodes": "Externe knooppunten", "billingNoLimitConfigured": "Geen limiet ingesteld", "billingEstimatedPeriod": "Geschatte Facturatie Periode", "billingIncludedUsage": "Opgenomen Gebruik", "billingIncludedUsageDescription": "Gebruik inbegrepen in uw huidige abonnementsplan", "billingFreeTierIncludedUsage": "Gratis niveau gebruikstoelagen", "billingIncluded": "inbegrepen", "billingEstimatedTotal": "Geschat Totaal:", "billingNotes": "Notities", "billingEstimateNote": "Dit is een schatting gebaseerd op uw huidige gebruik.", "billingActualChargesMayVary": "Facturering kan variëren.", "billingBilledAtEnd": "U wordt aan het einde van de factureringsperiode gefactureerd.", "billingModifySubscription": "Abonnementsaanpassing", "billingStartSubscription": "Abonnement Starten", "billingRecurringCharge": "Terugkerende Kosten", "billingManageSubscriptionSettings": "Beheer abonnementsinstellingen en voorkeuren", "billingNoActiveSubscription": "U heeft geen actief abonnement. Start uw abonnement om gebruikslimieten te verhogen.", "billingFailedToLoadSubscription": "Fout bij laden van abonnement", "billingFailedToLoadUsage": "Niet gelukt om gebruik te laden", "billingFailedToGetCheckoutUrl": "Niet gelukt om checkout URL te krijgen", "billingPleaseTryAgainLater": "Probeer het later opnieuw.", "billingCheckoutError": "Checkout Fout", "billingFailedToGetPortalUrl": "Niet gelukt om portal URL te krijgen", "billingPortalError": "Portal Fout", "billingDataUsageInfo": "U bent in rekening gebracht voor alle gegevens die via uw beveiligde tunnels via de cloud worden verzonden. Dit omvat zowel inkomende als uitgaande verkeer over al uw sites. Wanneer u uw limiet bereikt zullen uw sites de verbinding verbreken totdat u uw abonnement upgradet of het gebruik vermindert. Gegevens worden niet in rekening gebracht bij het gebruik van knooppunten.", "billingSInfo": "Hoeveel sites u kunt gebruiken", "billingUsersInfo": "Hoeveel gebruikers je kan gebruiken", "billingDomainInfo": "Hoeveel domeinen je kunt gebruiken", "billingRemoteExitNodesInfo": "Hoeveel externe nodes je kunt gebruiken", "billingLicenseKeys": "Licentie Sleutels", "billingLicenseKeysDescription": "Beheer uw licentiesleutelabonnementen", "billingLicenseSubscription": "Licentie abonnement", "billingInactive": "Inactief", "billingLicenseItem": "Licentie artikel", "billingQuantity": "Aantal", "billingTotal": "totaal", "billingModifyLicenses": "Licentieabonnement wijzigen", "domainNotFound": "Domein niet gevonden", "domainNotFoundDescription": "Deze bron is uitgeschakeld omdat het domein niet langer in ons systeem bestaat. Stel een nieuw domein in voor deze bron.", "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", "securityKeyRegister": "Nieuwe beveiligingssleutel registreren", "securityKeyList": "Uw beveiligingssleutels", "securityKeyNone": "Nog geen beveiligingssleutels geregistreerd", "securityKeyNameRequired": "Naam is verplicht", "securityKeyRemove": "Verwijderen", "securityKeyLastUsed": "Laatst gebruikt: {date}", "securityKeyNameLabel": "Naam", "securityKeyRegisterSuccess": "Beveiligingssleutel succesvol geregistreerd", "securityKeyRegisterError": "Fout bij registreren van beveiligingssleutel", "securityKeyRemoveSuccess": "Beveiligingssleutel succesvol verwijderd", "securityKeyRemoveError": "Fout bij verwijderen van beveiligingssleutel", "securityKeyLoadError": "Fout bij laden van beveiligingssleutels", "securityKeyLogin": "Gebruik beveiligingssleutel", "securityKeyAuthError": "Fout bij authenticatie met beveiligingssleutel", "securityKeyRecommendation": "Overweeg om een andere beveiligingssleutel te registreren op een ander apparaat om ervoor te zorgen dat u niet buitengesloten raakt van uw account.", "registering": "Registreren...", "securityKeyPrompt": "Verifieer je identiteit met je beveiligingssleutel. Zorg ervoor dat je beveiligingssleutel verbonden en klaar is.", "securityKeyBrowserNotSupported": "Je browser ondersteunt geen beveiligingssleutels. Gebruik een moderne browser zoals Chrome, Firefox of Safari.", "securityKeyPermissionDenied": "Verleen toegang tot je beveiligingssleutel om door te gaan met inloggen.", "securityKeyRemovedTooQuickly": "Houd je beveiligingssleutel verbonden totdat het inlogproces is voltooid.", "securityKeyNotSupported": "Je beveiligingssleutel is mogelijk niet compatibel. Probeer een andere beveiligingssleutel.", "securityKeyUnknownError": "Er was een probleem met het gebruik van je beveiligingssleutel. Probeer het opnieuw.", "twoFactorRequired": "Tweestapsverificatie is vereist om een beveiligingssleutel te registreren.", "twoFactor": "Tweestapsverificatie", "twoFactorAuthentication": "Tweestapsverificatie verificatie", "twoFactorDescription": "Deze organisatie vereist tweestapsverificatie.", "enableTwoFactor": "Tweestapsverificatie inschakelen", "organizationSecurityPolicy": "Organisatie Veiligheidsbeleid", "organizationSecurityPolicyDescription": "Deze organisatie heeft beveiligingsvereisten waaraan moet worden voldaan voordat u deze kunt openen", "securityRequirements": "Veiligheidsvereisten", "allRequirementsMet": "Aan alle vereisten is voldaan", "completeRequirementsToContinue": "Voltooi de onderstaande vereisten om toegang te blijven krijgen tot deze organisatie", "youCanNowAccessOrganization": "U heeft nu toegang tot deze organisatie", "reauthenticationRequired": "Sessie Lengte", "reauthenticationDescription": "Deze organisatie vereist dat u elke {maxDays} dagen inlogt.", "reauthenticationDescriptionHours": "Deze organisatie vereist dat u elke {maxHours} uur inlogt.", "reauthenticateNow": "Opnieuw inloggen", "adminEnabled2FaOnYourAccount": "Je beheerder heeft tweestapsverificatie voor {email} ingeschakeld. Voltooi het instellingsproces om verder te gaan.", "securityKeyAdd": "Beveiligingssleutel toevoegen", "securityKeyRegisterTitle": "Nieuwe beveiligingssleutel registreren", "securityKeyRegisterDescription": "Verbind je beveiligingssleutel en voer een naam in om deze te identificeren", "securityKeyTwoFactorRequired": "Tweestapsverificatie vereist", "securityKeyTwoFactorDescription": "Voer je tweestapsverificatiecode in om de beveiligingssleutel te registreren", "securityKeyTwoFactorRemoveDescription": "Voer je tweestapsverificatiecode in om de beveiligingssleutel te verwijderen", "securityKeyTwoFactorCode": "Tweestapsverificatiecode", "securityKeyRemoveTitle": "Beveiligingssleutel verwijderen", "securityKeyRemoveDescription": "Voer je wachtwoord in om de beveiligingssleutel \"{name}\" te verwijderen", "securityKeyNoKeysRegistered": "Geen beveiligingssleutels geregistreerd", "securityKeyNoKeysDescription": "Voeg een beveiligingssleutel toe om je accountbeveiliging te verbeteren", "createDomainRequired": "Domein is vereist", "createDomainAddDnsRecords": "DNS-records toevoegen", "createDomainAddDnsRecordsDescription": "Voeg de volgende DNS-records toe aan je domeinprovider om het instellen te voltooien.", "createDomainNsRecords": "NS-records", "createDomainRecord": "Record", "createDomainType": "Type:", "createDomainName": "Naam:", "createDomainValue": "Waarde:", "createDomainCnameRecords": "CNAME-records", "createDomainARecords": "A Records", "createDomainRecordNumber": "Record {number}", "createDomainTxtRecords": "TXT-records", "createDomainSaveTheseRecords": "Deze records opslaan", "createDomainSaveTheseRecordsDescription": "Zorg ervoor dat je deze DNS-records opslaat, want je zult ze niet opnieuw zien.", "createDomainDnsPropagation": "DNS-propagatie", "createDomainDnsPropagationDescription": "DNS-wijzigingen kunnen enige tijd duren om over het internet te worden verspreid. Dit kan enkele minuten tot 48 uur duren, afhankelijk van je DNS-provider en TTL-instellingen.", "resourcePortRequired": "Poortnummer is vereist voor niet-HTTP-bronnen", "resourcePortNotAllowed": "Poortnummer mag niet worden ingesteld voor HTTP-bronnen", "billingPricingCalculatorLink": "Prijs Calculator", "billingYourPlan": "Uw abonnement", "billingViewOrModifyPlan": "Bekijk of wijzig uw huidige abonnement", "billingViewPlanDetails": "Abonnementsdetails bekijken", "billingUsageAndLimits": "Gebruik en limieten", "billingViewUsageAndLimits": "Limiet van je abonnement en huidig gebruik bekijken", "billingCurrentUsage": "Huidig gebruik", "billingMaximumLimits": "Maximaal aantal limieten", "billingRemoteNodes": "Externe knooppunten", "billingUnlimited": "Onbeperkt", "billingPaidLicenseKeys": "Betaalde licentiesleutels", "billingManageLicenseSubscription": "Beheer je abonnement voor betaalde zelf gehoste licentiesleutels", "billingCurrentKeys": "Huidige toetsen", "billingModifyCurrentPlan": "Huidig plan wijzigen", "billingConfirmUpgrade": "Bevestig Upgrade", "billingConfirmDowngrade": "Downgraden bevestigen", "billingConfirmUpgradeDescription": "U staat op het punt uw abonnement te upgraden. Controleer de nieuwe limieten en prijzen hieronder.", "billingConfirmDowngradeDescription": "U staat op het punt om uw abonnement te downgraden. Controleer de nieuwe limieten en prijzen hieronder.", "billingPlanIncludes": "Abonnement bevat", "billingProcessing": "Verwerken...", "billingConfirmUpgradeButton": "Bevestig Upgrade", "billingConfirmDowngradeButton": "Downgraden bevestigen", "billingLimitViolationWarning": "Gebruik Overschrijdt nieuwe Plan Limieten", "billingLimitViolationDescription": "Uw huidige verbruik overschrijdt de limieten van dit plan. Na het downgraden worden alle acties uitgeschakeld totdat u het verbruik vermindert binnen de nieuwe grenzen. Controleer de onderstaande functies die de limieten overschrijden. Beperkingen in overtreding:", "billingFeatureLossWarning": "Kennisgeving beschikbaarheid", "billingFeatureLossDescription": "Door downgraden worden functies die niet beschikbaar zijn in het nieuwe abonnement automatisch uitgeschakeld. Sommige instellingen en configuraties kunnen verloren gaan. Raadpleeg de prijsmatrix om te begrijpen welke functies niet langer beschikbaar zijn.", "billingUsageExceedsLimit": "Huidig gebruik ({current}) overschrijdt limiet ({limit})", "billingPastDueTitle": "Vervaldatum betaling", "billingPastDueDescription": "Uw betaling is verlopen. Werk uw betaalmethode bij om uw huidige abonnementsfuncties te blijven gebruiken. Als dit niet is opgelost, zal je abonnement worden geannuleerd en zal je worden teruggezet naar de vrije rang.", "billingUnpaidTitle": "Abonnement Onbetaald", "billingUnpaidDescription": "Uw abonnement is niet betaald en u bent teruggekeerd naar het gratis niveau. Update uw betalingsmethode om uw abonnement te herstellen.", "billingIncompleteTitle": "Betaling onvolledig", "billingIncompleteDescription": "Uw betaling is onvolledig. Voltooi alstublieft het betalingsproces om uw abonnement te activeren.", "billingIncompleteExpiredTitle": "Betaling verlopen", "billingIncompleteExpiredDescription": "Uw betaling is nooit voltooid en verlopen. U bent teruggekeerd naar de gratis niveaus. Abonneer u opnieuw om de toegang tot betaalde functies te herstellen.", "billingManageSubscription": "Beheer uw abonnement", "billingResolvePaymentIssue": "Gelieve uw betalingsprobleem op te lossen voor het upgraden of downgraden", "signUpTerms": { "IAgreeToThe": "Ik ga akkoord met de", "termsOfService": "servicevoorwaarden", "and": "en", "privacyPolicy": "privacy beleid" }, "signUpMarketing": { "keepMeInTheLoop": "Houd me op de hoogte met nieuws, updates en nieuwe functies per e-mail." }, "siteRequired": "Site is vereist.", "olmTunnel": "Olm Tunnel", "olmTunnelDescription": "Gebruik Olm voor clientconnectiviteit", "errorCreatingClient": "Fout bij het aanmaken van de client", "clientDefaultsNotFound": "Standaardinstellingen van klant niet gevonden", "createClient": "Client aanmaken", "createClientDescription": "Maak een nieuwe client aan voor toegang tot privébronnen", "seeAllClients": "Alle clients bekijken", "clientInformation": "Klantinformatie", "clientNamePlaceholder": "Clientnaam", "address": "Adres", "subnetPlaceholder": "Subnet", "addressDescription": "Het interne adres van de klant. Moet binnen het subnetwerk van de organisatie vallen.", "selectSites": "Selecteer sites", "sitesDescription": "De client heeft connectiviteit met de geselecteerde sites", "clientInstallOlm": "Installeer Olm", "clientInstallOlmDescription": "Laat Olm draaien op uw systeem", "clientOlmCredentials": "Aanmeldgegevens", "clientOlmCredentialsDescription": "Dit is hoe de client zich zal verifiëren met de server", "olmEndpoint": "Endpoint", "olmId": "ID", "olmSecretKey": "Geheim", "clientCredentialsSave": "Sla de aanmeldgegevens op", "clientCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.", "generalSettingsDescription": "Configureer de algemene instellingen voor deze client", "clientUpdated": "Klant bijgewerkt ", "clientUpdatedDescription": "De client is bijgewerkt.", "clientUpdateFailed": "Het bijwerken van de client is mislukt", "clientUpdateError": "Er is een fout opgetreden tijdens het bijwerken van de client.", "sitesFetchFailed": "Het ophalen van sites is mislukt", "sitesFetchError": "Er is een fout opgetreden bij het ophalen van sites.", "olmErrorFetchReleases": "Er is een fout opgetreden bij het ophalen van Olm releases.", "olmErrorFetchLatest": "Er is een fout opgetreden bij het ophalen van de nieuwste Olm release.", "enterCidrRange": "Voer CIDR-bereik in", "resourceEnableProxy": "Openbare proxy inschakelen", "resourceEnableProxyDescription": "Schakel publieke proxy in voor deze resource. Dit maakt toegang tot de resource mogelijk vanuit het netwerk via de cloud met een open poort. Vereist Traefik-configuratie.", "externalProxyEnabled": "Externe Proxy Ingeschakeld", "addNewTarget": "Voeg nieuw doelwit toe", "targetsList": "Lijst met doelen", "advancedMode": "Geavanceerde modus", "advancedSettings": "Geavanceerde instellingen", "targetErrorDuplicateTargetFound": "Dubbel doelwit gevonden", "healthCheckHealthy": "Gezond", "healthCheckUnhealthy": "Ongezond", "healthCheckUnknown": "Onbekend", "healthCheck": "Gezondheidscontrole", "configureHealthCheck": "Configureer Gezondheidscontrole", "configureHealthCheckDescription": "Stel gezondheid monitor voor {target} in", "enableHealthChecks": "Inschakelen Gezondheidscontroles", "enableHealthChecksDescription": "Controleer de gezondheid van dit doel. U kunt een ander eindpunt monitoren dan het doel indien vereist.", "healthScheme": "Methode", "healthSelectScheme": "Selecteer methode", "healthCheckPortInvalid": "Health check poort moet tussen 1 en 65535 zijn", "healthCheckPath": "Pad", "healthHostname": "IP / Hostnaam", "healthPort": "Poort", "healthCheckPathDescription": "Het pad om de gezondheid status te controleren.", "healthyIntervalSeconds": "Gezonde Interval (sec)", "unhealthyIntervalSeconds": "Ongezonde Interval (sec)", "IntervalSeconds": "Gezonde Interval", "timeoutSeconds": "Timeout (sec)", "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": "Komma's gescheiden lijst van commando's waar de gebruiker een sudo mee mag uitvoeren.", "sshCreateHomeDir": "Maak Home Directory", "sshUnixGroups": "Unix groepen", "sshUnixGroupsDescription": "Door komma's gescheiden Unix-groepen om de gebruiker toe te voegen aan de doelhost.", "retryAttempts": "Herhaal Pogingen", "expectedResponseCodes": "Verwachte Reactiecodes", "expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.", "customHeaders": "Aangepaste headers", "customHeadersDescription": "Kopregeleinde: Header-Naam: waarde", "headersValidationError": "Headers moeten in het formaat zijn: Header-Naam: waarde.", "saveHealthCheck": "Opslaan Gezondheidscontrole", "healthCheckSaved": "Gezondheidscontrole Opgeslagen", "healthCheckSavedDescription": "Gezondheidscontrole configuratie succesvol opgeslagen", "healthCheckError": "Gezondheidscontrole Fout", "healthCheckErrorDescription": "Er is een fout opgetreden bij het opslaan van de configuratie van de gezondheidscontrole.", "healthCheckPathRequired": "Gezondheidscontrole pad is vereist", "healthCheckMethodRequired": "HTTP methode is vereist", "healthCheckIntervalMin": "Controle interval moet minimaal 5 seconden zijn", "healthCheckTimeoutMin": "Timeout moet minimaal 1 seconde zijn", "healthCheckRetryMin": "Herhaal pogingen moet minimaal 1 zijn", "httpMethod": "HTTP-methode", "selectHttpMethod": "Selecteer HTTP-methode", "domainPickerSubdomainLabel": "Subdomein", "domainPickerBaseDomainLabel": "Basisdomein", "domainPickerSearchDomains": "Zoek domeinen...", "domainPickerNoDomainsFound": "Geen domeinen gevonden", "domainPickerLoadingDomains": "Domeinen laden...", "domainPickerSelectBaseDomain": "Selecteer basisdomein...", "domainPickerNotAvailableForCname": "Niet beschikbaar voor CNAME-domeinen", "domainPickerEnterSubdomainOrLeaveBlank": "Voer een subdomein in of laat leeg om basisdomein te gebruiken.", "domainPickerEnterSubdomainToSearch": "Voer een subdomein in om te zoeken en te selecteren uit beschikbare gratis domeinen.", "domainPickerFreeDomains": "Gratis Domeinen", "domainPickerSearchForAvailableDomains": "Zoek naar beschikbare domeinen", "domainPickerNotWorkSelfHosted": "Opmerking: Gratis aangeboden domeinen zijn momenteel niet beschikbaar voor zelf-gehoste instanties.", "resourceDomain": "Domein", "resourceEditDomain": "Domein bewerken", "siteName": "Site Naam", "proxyPort": "Poort", "resourcesTableProxyResources": "Openbaar", "resourcesTableClientResources": "Privé", "resourcesTableNoProxyResourcesFound": "Geen proxybronnen gevonden.", "resourcesTableNoInternalResourcesFound": "Geen interne bronnen gevonden.", "resourcesTableDestination": "Bestemming", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Alias adres", "resourcesTableAliasAddressInfo": "Dit adres is onderdeel van het hulpprogramma subnet van de organisatie. Het wordt gebruikt om aliasrecords op te lossen met behulp van interne DNS-resolutie.", "resourcesTableClients": "Clienten", "resourcesTableAndOnlyAccessibleInternally": "en zijn alleen intern toegankelijk wanneer verbonden met een client.", "resourcesTableNoTargets": "Geen doelen", "resourcesTableHealthy": "Gezond", "resourcesTableDegraded": "Verminderde", "resourcesTableOffline": "Offline", "resourcesTableUnknown": "onbekend", "resourcesTableNotMonitored": "Niet gecontroleerd", "editInternalResourceDialogEditClientResource": "Privépagina bewerken", "editInternalResourceDialogUpdateResourceProperties": "Update de resource configuratie en access control voor {resourceName}", "editInternalResourceDialogResourceProperties": "Bron eigenschappen", "editInternalResourceDialogName": "Naam", "editInternalResourceDialogProtocol": "Protocol", "editInternalResourceDialogSitePort": "Site Poort", "editInternalResourceDialogTargetConfiguration": "Doelconfiguratie", "editInternalResourceDialogCancel": "Annuleren", "editInternalResourceDialogSaveResource": "Sla bron op", "editInternalResourceDialogSuccess": "Succes", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Interne bron succesvol bijgewerkt", "editInternalResourceDialogError": "Fout", "editInternalResourceDialogFailedToUpdateInternalResource": "Het bijwerken van de interne bron is mislukt", "editInternalResourceDialogNameRequired": "Naam is verplicht", "editInternalResourceDialogNameMaxLength": "Naam mag niet langer zijn dan 255 tekens", "editInternalResourceDialogProxyPortMin": "Proxy poort moet minstens 1 zijn", "editInternalResourceDialogProxyPortMax": "Proxy poort moet minder dan 65536 zijn", "editInternalResourceDialogInvalidIPAddressFormat": "Ongeldig IP-adresformaat", "editInternalResourceDialogDestinationPortMin": "Bestemmingspoort moet minstens 1 zijn", "editInternalResourceDialogDestinationPortMax": "Bestemmingspoort moet minder dan 65536 zijn", "editInternalResourceDialogPortModeRequired": "Protocol, proxy poort en bestemmingspoort zijn vereist voor poortmodus", "editInternalResourceDialogMode": "Modus", "editInternalResourceDialogModePort": "Poort", "editInternalResourceDialogModeHost": "Hostnaam", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Bestemming", "editInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.", "editInternalResourceDialogDestinationIPDescription": "Het IP of hostnaam adres van de bron op het netwerk van de site.", "editInternalResourceDialogDestinationCidrDescription": "Het CIDR-bereik van het document op het netwerk van de site.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Een optionele interne DNS-alias voor dit document.", "createInternalResourceDialogNoSitesAvailable": "Geen sites beschikbaar", "createInternalResourceDialogNoSitesAvailableDescription": "U moet ten minste één Newt-site hebben met een geconfigureerd subnet om interne bronnen aan te maken.", "createInternalResourceDialogClose": "Sluiten", "createInternalResourceDialogCreateClientResource": "Privé bron maken", "createInternalResourceDialogCreateClientResourceDescription": "Maak een nieuwe bron aan die alleen toegankelijk is voor klanten die verbonden zijn met de organisatie", "createInternalResourceDialogResourceProperties": "Bron-eigenschappen", "createInternalResourceDialogName": "Naam", "createInternalResourceDialogSite": "Site", "selectSite": "Selecteer site...", "noSitesFound": "Geen sites gevonden.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Site Poort", "createInternalResourceDialogSitePortDescription": "Gebruik deze poort om toegang te krijgen tot de bron op de site wanneer verbonden met een client.", "createInternalResourceDialogTargetConfiguration": "Doelconfiguratie", "createInternalResourceDialogDestinationIPDescription": "Het IP of hostnaam adres van de bron op het netwerk van de site.", "createInternalResourceDialogDestinationPortDescription": "De poort op het bestemmings-IP waar de bron toegankelijk is.", "createInternalResourceDialogCancel": "Annuleren", "createInternalResourceDialogCreateResource": "Bron aanmaken", "createInternalResourceDialogSuccess": "Succes", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Interne bron succesvol aangemaakt", "createInternalResourceDialogError": "Fout", "createInternalResourceDialogFailedToCreateInternalResource": "Het aanmaken van de interne bron is mislukt", "createInternalResourceDialogNameRequired": "Naam is verplicht", "createInternalResourceDialogNameMaxLength": "Naam mag niet langer zijn dan 255 tekens", "createInternalResourceDialogPleaseSelectSite": "Selecteer alstublieft een site", "createInternalResourceDialogProxyPortMin": "Proxy poort moet minstens 1 zijn", "createInternalResourceDialogProxyPortMax": "Proxy poort moet minder dan 65536 zijn", "createInternalResourceDialogInvalidIPAddressFormat": "Ongeldig IP-adresformaat", "createInternalResourceDialogDestinationPortMin": "Bestemmingspoort moet minstens 1 zijn", "createInternalResourceDialogDestinationPortMax": "Bestemmingspoort moet minder dan 65536 zijn", "createInternalResourceDialogPortModeRequired": "Protocol, proxy poort en bestemmingspoort zijn vereist voor poortmodus", "createInternalResourceDialogMode": "Modus", "createInternalResourceDialogModePort": "Poort", "createInternalResourceDialogModeHost": "Hostnaam", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Bestemming", "createInternalResourceDialogDestinationHostDescription": "Het IP-adres of de hostnaam van de bron op het netwerk van de site.", "createInternalResourceDialogDestinationCidrDescription": "Het CIDR-bereik van het document op het netwerk van de site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Een optionele interne DNS-alias voor dit document.", "siteConfiguration": "Configuratie", "siteAcceptClientConnections": "Accepteer clientverbindingen", "siteAcceptClientConnectionsDescription": "Sta gebruikersapparaten en clients toegang toe tot bronnen op deze site. Dit kan later worden gewijzigd.", "siteAddress": "Website Adres (Geavanceerd)", "siteAddressDescription": "Het interne adres van de site. Moet binnen het subnetwerk van de organisatie vallen.", "siteNameDescription": "De weergavenaam van de site die later gewijzigd kan worden.", "autoLoginExternalIdp": "Auto Login met Externe IDP", "autoLoginExternalIdpDescription": "Leidt de gebruiker onmiddellijk door naar de externe identiteitsprovider voor authenticatie.", "selectIdp": "Selecteer IDP", "selectIdpPlaceholder": "Kies een IDP...", "selectIdpRequired": "Selecteer alstublieft een IDP wanneer automatisch inloggen is ingeschakeld.", "autoLoginTitle": "Omleiden", "autoLoginDescription": "Je wordt doorverwezen naar de externe identity provider voor authenticatie.", "autoLoginProcessing": "Authenticatie voorbereiden...", "autoLoginRedirecting": "Redirecting naar inloggen...", "autoLoginError": "Auto Login Fout", "autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.", "autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt.", "remoteExitNodeManageRemoteExitNodes": "Externe knooppunten", "remoteExitNodeDescription": "Host je eigen externe relais- en proxyserverknooppunten zelf", "remoteExitNodes": "Nodes", "searchRemoteExitNodes": "Knooppunten zoeken...", "remoteExitNodeAdd": "Voeg node toe", "remoteExitNodeErrorDelete": "Fout bij verwijderen node", "remoteExitNodeQuestionRemove": "Weet u zeker dat u het knooppunt uit de organisatie wilt verwijderen?", "remoteExitNodeMessageRemove": "Eenmaal verwijderd, zal het knooppunt niet langer toegankelijk zijn.", "remoteExitNodeConfirmDelete": "Bevestig verwijderen node", "remoteExitNodeDelete": "Knoop verwijderen", "sidebarRemoteExitNodes": "Externe knooppunten", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Geheim", "remoteExitNodeCreate": { "title": "Externe knoop aanmaken", "description": "Maak een nieuwe zelf-gehoste externe relais- en proxyservermodule", "viewAllButton": "Alle nodes weergeven", "strategy": { "title": "Creatie Strategie", "description": "Selecteer hoe u de externe knoop wilt aanmaken", "adopt": { "title": "Adopteer Node", "description": "Kies dit als u al de referenties voor deze node heeft" }, "generate": { "title": "Genereer Sleutels", "description": "Kies dit als u nieuwe sleutels voor het knooppunt wilt genereren." } }, "adopt": { "title": "Adopteer Bestaande Node", "description": "Voer de referenties in van het bestaande knooppunt dat u wilt adopteren", "nodeIdLabel": "Knooppunt ID", "nodeIdDescription": "De ID van het knooppunt dat u wilt adopteren", "secretLabel": "Geheim", "secretDescription": "De geheime sleutel van de bestaande node", "submitButton": "Knooppunt adopteren" }, "generate": { "title": "Gegeneerde Inloggegevens", "description": "Gebruik deze gegenereerde inloggegevens om het knooppunt te configureren", "nodeIdTitle": "Knooppunt ID", "secretTitle": "Geheim", "saveCredentialsTitle": "Voeg Inloggegevens toe aan Config", "saveCredentialsDescription": "Voeg deze inloggegevens toe aan uw zelf-gehoste Pangolin-node configuratiebestand om de verbinding te voltooien.", "submitButton": "Maak node" }, "validation": { "adoptRequired": "Node ID en Secret zijn verplicht bij het overnemen van een bestaand knooppunt" }, "errors": { "loadDefaultsFailed": "Niet gelukt om standaarden te laden", "defaultsNotLoaded": "Standaarden niet geladen", "createFailed": "Fout bij het maken van node" }, "success": { "created": "Node succesvol aangemaakt" } }, "remoteExitNodeSelection": "Knooppunt selectie", "remoteExitNodeSelectionDescription": "Selecteer een node om het verkeer door te leiden voor deze lokale site", "remoteExitNodeRequired": "Een node moet worden geselecteerd voor lokale sites", "noRemoteExitNodesAvailable": "Geen knooppunten beschikbaar", "noRemoteExitNodesAvailableDescription": "Er zijn geen knooppunten beschikbaar voor deze organisatie. Maak eerst een knooppunt aan om lokale sites te gebruiken.", "exitNode": "Exit Node", "country": "Land", "rulesMatchCountry": "Momenteel gebaseerd op bron IP", "managedSelfHosted": { "title": "Beheerde Self-Hosted", "description": "betrouwbaardere en slecht onderhouden Pangolin server met extra klokken en klokkenluiders", "introTitle": "Beheerde zelfgehoste pangolin", "introDescription": "is een implementatieoptie ontworpen voor mensen die eenvoud en extra betrouwbaarheid willen, terwijl hun gegevens privé en zelf georganiseerd blijven.", "introDetail": "Met deze optie beheert u nog steeds uw eigen Pangolin node - uw tunnels, SSL-verbinding en verkeer alles op uw server. Het verschil is dat beheer en monitoring worden behandeld via onze cloud dashboard, wat een aantal voordelen oplevert:", "benefitSimplerOperations": { "title": "Simpler operaties", "description": "Je hoeft geen eigen mailserver te draaien of complexe waarschuwingen in te stellen. Je krijgt gezondheidscontroles en downtime meldingen uit de box." }, "benefitAutomaticUpdates": { "title": "Automatische updates", "description": "Het cloud dashboard evolueert snel, zodat u nieuwe functies en bug fixes krijgt zonder elke keer handmatig nieuwe containers te moeten trekken." }, "benefitLessMaintenance": { "title": "Minder onderhoud", "description": "Geen database migratie, back-ups of extra infrastructuur om te beheren. Dat behandelen we in de cloud." }, "benefitCloudFailover": { "title": "Cloud fout", "description": "Als uw node omlaag gaat, kunnen uw tunnels tijdelijk niet meer naar onze aanwezigheidspunten gaan totdat u hem weer online brengt." }, "benefitHighAvailability": { "title": "Hoge beschikbaarheid (PoPs)", "description": "U kunt ook meerdere nodes koppelen aan uw account voor ontslag en betere prestaties." }, "benefitFutureEnhancements": { "title": "Toekomstige verbeteringen", "description": "We zijn van plan om meer analytica, waarschuwing en beheerhulpmiddelen toe te voegen om uw implementatie nog steviger te maken." }, "docsAlert": { "text": "Meer informatie over de optie voor zelf-verzorging in onze", "documentation": "documentatie" }, "convertButton": "Converteer deze node naar Beheerde Zelf-Hosted" }, "internationaldomaindetected": "Internationaal Domein Gedetecteerd", "willbestoredas": "Zal worden opgeslagen als:", "roleMappingDescription": "Bepaal hoe rollen worden toegewezen aan gebruikers wanneer ze inloggen wanneer Auto Provision is ingeschakeld.", "selectRole": "Selecteer een rol", "roleMappingExpression": "Expressie", "selectRolePlaceholder": "Kies een rol", "selectRoleDescription": "Selecteer een rol om toe te wijzen aan alle gebruikers van deze identiteitsprovider", "roleMappingExpressionDescription": "Voer een JMESPath expressie in om rolinformatie van de ID-token te extraheren", "idpTenantIdRequired": "Tenant ID is vereist", "invalidValue": "Ongeldige waarde", "idpTypeLabel": "Identiteit provider type", "roleMappingExpressionPlaceholder": "bijvoorbeeld bevat (groepen, 'admin') && 'Admin' ½ 'Member'", "idpGoogleConfiguration": "Google Configuratie", "idpGoogleConfigurationDescription": "Configureer de Google OAuth2-referenties", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Google OAuth2 Clientgeheim", "idpAzureConfiguration": "Azure Entra ID configuratie", "idpAzureConfigurationDescription": "Azure Entra ID OAuth2 referenties configureren", "idpTenantId": "Tenant-ID", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "Azure tenant ID (gevonden in Azure Active Directory overzicht)", "idpAzureClientIdDescription": "Azure App registratie Client ID", "idpAzureClientSecretDescription": "Azure App registratie client geheim", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Google Configuratie", "idpAzureConfigurationTitle": "Azure Entra ID configuratie", "idpTenantIdLabel": "Tenant-ID", "idpAzureClientIdDescription2": "Azure App registratie Client ID", "idpAzureClientSecretDescription2": "Azure App registratie client geheim", "idpGoogleDescription": "Google OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnet", "subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.", "customDomain": "Aangepast domein", "authPage": "Authenticatiepagina's", "authPageDescription": "Stel een aangepast domein in voor de authenticatiepagina's van de organisatie", "authPageDomain": "Authenticatie pagina domein", "authPageBranding": "Aangepaste branding", "authPageBrandingDescription": "Configureer de branding die op de authenticatiepagina's voor deze organisatie verschijnt", "authPageBrandingUpdated": "Auth-paginamerken succesvol bijgewerkt", "authPageBrandingRemoved": "Configuratie hiervan is succesvol verwijderd.", "authPageBrandingRemoveTitle": "Verwijder Auth-pagina Branding", "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)", "brandingOrgTitle": "Titel voor organisatie-authenticatiepagina", "brandingOrgDescription": "{orgName} wordt vervangen door de naam van de organisatie", "brandingOrgSubtitle": "Ondertitel voor organisatie-authenticatiepagina", "brandingResourceTitle": "Titel voor bron-authenticatiepagina", "brandingResourceSubtitle": "Ondertitel voor bron-authenticatiepagina", "brandingResourceDescription": "{resourceName} wordt vervangen door de naam van de organisatie", "saveAuthPageDomain": "Domein opslaan", "saveAuthPageBranding": "Branding opslaan", "removeAuthPageBranding": "Branding verwijderen", "noDomainSet": "Geen domein ingesteld", "changeDomain": "Domein wijzigen", "selectDomain": "Domein selecteren", "restartCertificate": "Certificaat opnieuw starten", "editAuthPageDomain": "Authenticatiepagina domein bewerken", "setAuthPageDomain": "Authenticatiepagina domein instellen", "failedToFetchCertificate": "Certificaat ophalen mislukt", "failedToRestartCertificate": "Kon certificaat niet opnieuw opstarten", "addDomainToEnableCustomAuthPages": "Gebruikers kunnen toegang krijgen tot de inlogpagina van de organisatie en de bronauthenticatie voltooien met dit domein.", "selectDomainForOrgAuthPage": "Selecteer een domein voor de authenticatiepagina van de organisatie", "domainPickerProvidedDomain": "Opgegeven domein", "domainPickerFreeProvidedDomain": "Gratis verstrekt domein", "domainPickerVerified": "Geverifieerd", "domainPickerUnverified": "Ongeverifieerd", "domainPickerInvalidSubdomainStructure": "Dit subdomein bevat ongeldige tekens of structuur. Het zal automatisch worden gesaneerd wanneer u opslaat.", "domainPickerError": "Foutmelding", "domainPickerErrorLoadDomains": "Fout bij het laden van organisatiedomeinen", "domainPickerErrorCheckAvailability": "Kan domein beschikbaarheid niet controleren", "domainPickerInvalidSubdomain": "Ongeldig subdomein", "domainPickerInvalidSubdomainRemoved": "De invoer \"{sub}\" is verwijderd omdat het niet geldig is.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kon niet geldig worden gemaakt voor {domain}.", "domainPickerSubdomainSanitized": "Subdomein gesaniseerd", "domainPickerSubdomainCorrected": "\"{sub}\" was gecorrigeerd op \"{sanitized}\"", "orgAuthSignInTitle": "Organisatie Inloggen", "orgAuthChooseIdpDescription": "Kies uw identiteitsprovider om door te gaan", "orgAuthNoIdpConfigured": "Deze organisatie heeft geen identiteitsproviders geconfigureerd. Je kunt in plaats daarvan inloggen met je Pangolin-identiteit.", "orgAuthSignInWithPangolin": "Log in met Pangolin", "orgAuthSignInToOrg": "Log in bij een organisatie", "orgAuthSelectOrgTitle": "Organisatie Inloggen", "orgAuthSelectOrgDescription": "Voer je organisatie-ID in om verder te gaan", "orgAuthOrgIdPlaceholder": "jouw-organisatie", "orgAuthOrgIdHelp": "Voer de unieke ID van jouw organisatie in", "orgAuthSelectOrgHelp": "Na het invoeren van je organisatie-ID, word je doorgestuurd naar de inlogpagina van je organisatie waar je SSO kunt gebruiken of de gegevens van je organisatie.", "orgAuthRememberOrgId": "Vergeet deze organisatie-ID niet", "orgAuthBackToSignIn": "Terug naar standaard aanmelden", "orgAuthNoAccount": "Nog geen account?", "subscriptionRequiredToUse": "Een abonnement is vereist om deze functie te gebruiken.", "mustUpgradeToUse": "U moet uw abonnement upgraden om deze functie te gebruiken.", "subscriptionRequiredTierToUse": "Deze functie vereist {tier} of hoger.", "upgradeToTierToUse": "Upgrade naar {tier} of hoger om deze functie te gebruiken.", "subscriptionTierTier1": "Startpagina", "subscriptionTierTier2": "Team", "subscriptionTierTier3": "Bedrijfsleven", "subscriptionTierEnterprise": "Onderneming", "idpDisabled": "Identiteitsaanbieders zijn uitgeschakeld.", "orgAuthPageDisabled": "Pagina voor organisatie-authenticatie is uitgeschakeld.", "domainRestartedDescription": "Domeinverificatie met succes opnieuw gestart", "resourceAddEntrypointsEditFile": "Bestand bewerken: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Bestand bewerken: docker-compose.yml", "emailVerificationRequired": "E-mail verificatie is vereist. Log opnieuw in via {dashboardUrl}/auth/login voltooide deze stap. Kom daarna hier terug.", "twoFactorSetupRequired": "Tweestapsverificatie instellen is vereist. Log opnieuw in via {dashboardUrl}/auth/login voltooide deze stap. Kom daarna hier terug.", "additionalSecurityRequired": "Extra beveiliging vereist", "organizationRequiresAdditionalSteps": "Deze organisatie vereist extra beveiligingsstappen voordat u toegang hebt tot de bronnen.", "completeTheseSteps": "Voltooi deze stappen", "enableTwoFactorAuthentication": "Tweestapsverificatie inschakelen", "completeSecuritySteps": "Voltooi beveiligingsstappen", "securitySettings": "Beveiliging instellingen", "dangerSection": "Gevaarlijke zone", "dangerSectionDescription": "Verwijder permanent alle gegevens die aan deze organisatie zijn gekoppeld", "securitySettingsDescription": "Beveiligingsbeleid voor de organisatie configureren", "requireTwoFactorForAllUsers": "Authenticatie in twee stappen vereist voor alle gebruikers", "requireTwoFactorDescription": "Wanneer ingeschakeld, moeten alle interne gebruikers in deze organisatie tweestapsverificatie ingeschakeld hebben om toegang te krijgen tot de organisatie.", "requireTwoFactorDisabledDescription": "Deze functie vereist een geldig licentie (Enterprise) of actief abonnement (SaaS)", "requireTwoFactorCannotEnableDescription": "U moet tweestapsverificatie inschakelen voor uw account voordat u deze voor alle gebruikers kan afdwingen", "maxSessionLength": "Maximale sessielengte", "maxSessionLengthDescription": "Stel de maximale duur van de gebruikerssessies in. Na deze tijd zullen gebruikers opnieuw moeten verifiëren.", "maxSessionLengthDisabledDescription": "Deze functie vereist een geldig licentie (Enterprise) of actief abonnement (SaaS)", "selectSessionLength": "Selecteer sessie lengte", "unenforced": "Onafgedwongen", "1Hour": "1 uur", "3Hours": "3 uur", "6Hours": "6 uur", "12Hours": "12 uur", "1DaySession": "1 dag", "3Days": "3 dagen", "7Days": "7 dagen", "14Days": "14 dagen", "30DaysSession": "30 dagen", "90DaysSession": "90 dagen", "180DaysSession": "180 dagen", "passwordExpiryDays": "Wachtwoord verloopt", "editPasswordExpiryDescription": "Stel het aantal dagen in voordat gebruikers verplicht zijn hun wachtwoord te wijzigen.", "selectPasswordExpiry": "Selecteer wachtwoord vervaldatum", "30Days": "30 dagen", "1Day": "1 dag", "60Days": "60 dagen", "90Days": "90 dagen", "180Days": "180 dagen", "1Year": "1 jaar", "subscriptionBadge": "Abonnement vereist", "securityPolicyChangeWarning": "Waarschuwing wijzigen beveiligingsbeleid", "securityPolicyChangeDescription": "U staat op het punt om de instellingen van het beveiligingsbeleid te wijzigen. Na het opslaan moet u zich opnieuw aanmelden om te voldoen aan deze beleidsupdates. Alle gebruikers die niet aan de voorwaarden voldoen, moeten zich ook opnieuw authenticeren.", "securityPolicyChangeConfirmMessage": "Ik bevestig", "securityPolicyChangeWarningText": "Dit heeft invloed op alle gebruikers in de organisatie", "authPageErrorUpdateMessage": "Er is een fout opgetreden bij het bijwerken van de instellingen van de auth-pagina", "authPageErrorUpdate": "Kan de autorisatiepagina niet bijwerken", "authPageDomainUpdated": "Auth-pagina domein succesvol bijgewerkt", "healthCheckNotAvailable": "Lokaal", "rewritePath": "Herschrijf Pad", "rewritePathDescription": "Optioneel het pad herschrijven voordat je het naar het doel doorstuurt.", "continueToApplication": "Doorgaan naar applicatie", "checkingInvite": "Uitnodiging controleren", "setResourceHeaderAuth": "stelResourceHeaderAuth", "resourceHeaderAuthRemove": "Auth koptekst verwijderen", "resourceHeaderAuthRemoveDescription": "Koptekst authenticatie succesvol verwijderd.", "resourceErrorHeaderAuthRemove": "Kan Header-authenticatie niet verwijderen", "resourceErrorHeaderAuthRemoveDescription": "Kon header authenticatie niet verwijderen voor de bron.", "resourceHeaderAuthProtectionEnabled": "Koptekst authenticatie ingeschakeld", "resourceHeaderAuthProtectionDisabled": "Header authenticatie uitgeschakeld", "headerAuthRemove": "Auth koptekst verwijderen", "headerAuthAdd": "Kopsauth toevoegen", "resourceErrorHeaderAuthSetup": "Kan Header Authenticatie niet instellen", "resourceErrorHeaderAuthSetupDescription": "Kan geen header authenticatie instellen voor de bron.", "resourceHeaderAuthSetup": "Header Authenticatie set succesvol", "resourceHeaderAuthSetupDescription": "Header authenticatie is met succes ingesteld.", "resourceHeaderAuthSetupTitle": "Header Authenticatie instellen", "resourceHeaderAuthSetupTitleDescription": "Stel de basis authenticatiegegevens (gebruikersnaam en wachtwoord) in om deze bron te beschermen met HTTP Header Authenticatie. Gebruik het formaat https://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Header Authenticatie instellen", "actionSetResourceHeaderAuth": "Header Authenticatie instellen", "enterpriseEdition": "Enterprise Edition", "unlicensed": "Ongelicentieerd", "beta": "Bèta", "manageUserDevices": "Gebruiker Apparaten", "manageUserDevicesDescription": "Bekijk en beheer apparaten die gebruikers gebruiken om privé verbinding te maken met bronnen", "downloadClientBannerTitle": "Download Pangolin Client", "downloadClientBannerDescription": "Download de Pangolin-client voor je systeem om verbinding te maken met het Pangolin-netwerk en resources privé te benaderen.", "manageMachineClients": "Beheer Machine Clients", "manageMachineClientsDescription": "Creëer en beheer clients die servers en systemen gebruiken om privé verbinding te maken met bronnen", "machineClientsBannerTitle": "Servers & Geautomatiseerde Systemen", "machineClientsBannerDescription": "Machineclients zijn bedoeld voor servers en geautomatiseerde systemen die niet aan een specifieke gebruiker zijn gekoppeld. Ze verifiëren met een ID en geheim, en kunnen draaien met Pangolin CLI, Olm CLI, of Olm als een container.", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Olm-container", "clientsTableUserClients": "Gebruiker", "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Geldig tot", "saasLicenseKeysSettingsTitle": "Enterprise Licenties", "saasLicenseKeysSettingsDescription": "Genereer en beheer de Enterprise licentiesleutels voor zelfgehoste Pangolin instanties", "sidebarEnterpriseLicenses": "Licenties", "generateLicenseKey": "Licentiesleutel genereren", "generateLicenseKeyForm": { "validation": { "emailRequired": "Voer een geldig e-mailadres in", "useCaseTypeRequired": "Selecteer een type voor gebruik", "firstNameRequired": "Voornaam is vereist", "lastNameRequired": "Achternaam is vereist", "primaryUseRequired": "Beschrijf uw primaire gebruik", "jobTitleRequiredBusiness": "Functitel is vereist voor business gebruik", "industryRequiredBusiness": "Industrie is vereist voor zakelijk gebruik", "stateProvinceRegionRequired": "Provincie/Regio is vereist", "postalZipCodeRequired": "Postcode is vereist", "companyNameRequiredBusiness": "Bedrijfsnaam is vereist voor zakelijk gebruik", "countryOfResidenceRequiredBusiness": "Land van verblijf is vereist voor zakelijk gebruik", "countryRequiredPersonal": "Land is vereist voor persoonlijk gebruik", "agreeToTermsRequired": "U moet akkoord gaan met de voorwaarden", "complianceConfirmationRequired": "U moet de naleving van de Fossorial Commerciële licentie bevestigen" }, "useCaseOptions": { "personal": { "title": "Persoonlijk gebruik", "description": "Voor individueel, niet-commercieel gebruik, zoals leren, persoonlijke projecten of experimenten." }, "business": { "title": "Zakelijk gebruik", "description": "Voor gebruik binnen organisaties, bedrijven, commerciële of inkomstengenererende activiteiten." } }, "steps": { "emailLicenseType": { "title": "E-mail & Licentie Type", "description": "Voer uw e-mailadres in en kies uw licentietype" }, "personalInformation": { "title": "Persoonlijke informatie", "description": "Vertel ons over jezelf" }, "contactInformation": { "title": "Contact informatie", "description": "Uw contactgegevens" }, "termsGenerate": { "title": "Voorwaarden & Genereer", "description": "Controleer en accepteer termen om uw licentie te genereren" } }, "alerts": { "commercialUseDisclosure": { "title": "Disclosure voor gebruik", "description": "Selecteer de licentielift die precies overeenkomt met het beoogde gebruik. De Persoonlijke Licentie staat het gratis gebruik van de software toe voor individuele, niet-commerciële of kleinschalige commerciële activiteiten met jaarlijkse bruto inkomsten van minder dan $100.000 USD. Elk gebruik buiten deze grenzen - inclusief gebruik binnen een bedrijf, organisatie, of andere inkomstengenererende omgeving - vereist een geldige Enterprise-licentie en betaling van de toepasselijke licentiekosten. Alle gebruikers, Persoonlijk of Enterprise, moeten voldoen aan de Fossorial Commerciële Licentievoorwaarden." }, "trialPeriodInformation": { "title": "Informatie proefperiode", "description": "Deze licentiesleutel maakt bedrijfsfuncties mogelijk voor een evaluatieperiode van 7 dagen. Continue toegang tot betaalde functies na de evaluatieperiode vereist activering onder een geldige Persoonlijke of Enterprise License. Voor een Enterprise licentie, neem contact op met sales@pangolin.net." } }, "form": { "useCaseQuestion": "Gebruikt u Pangolin voor persoonlijk of zakelijk gebruik?", "firstName": "Voornaam is vereist.", "lastName": "Achternaam is vereist", "jobTitle": "Job titel", "primaryUseQuestion": "Waar bent u primair van plan Pangolin voor te gebruiken?", "industryQuestion": "Wat is uw industrie?", "prospectiveUsersQuestion": "Hoeveel potentiële gebruikers verwacht je te hebben?", "prospectiveSitesQuestion": "Hoeveel potentiële sites (tunnels) verwacht je te hebben?", "companyName": "Naam bedrijf", "countryOfResidence": "Land van verblijf", "stateProvinceRegion": "Staat / Provincie / Regio", "postalZipCode": "Postcode / postcode", "companyWebsite": "Bedrijfs website", "companyPhoneNumber": "Bedrijfs telefoonnummer", "country": "Land", "phoneNumberOptional": "Telefoonnummer (optioneel)", "complianceConfirmation": "Ik bevestig dat de door mij verstrekte informatie juist is en dat ik mij aan de Fossorial Commerciële licentie houd. Het melden van onjuiste informatie of het verkeerd identificeren van het gebruik van het product is een schending van de licentie en kan leiden tot het intrekken van uw sleutel." }, "buttons": { "close": "Sluiten", "previous": "named@@0", "next": "Volgende", "generateLicenseKey": "Licentiesleutel genereren" }, "toasts": { "success": { "title": "Licentiesleutel succesvol gegenereerd", "description": "Uw licentiesleutel is gegenereerd en is klaar voor gebruik." }, "error": { "title": "Kan licentiesleutel niet genereren", "description": "Fout opgetreden tijdens het genereren van de licentiesleutel." } } }, "newPricingLicenseForm": { "title": "Krijg een licentie", "description": "Kies een plan en vertel ons hoe u Pangolin wilt gebruiken.", "chooseTier": "Kies uw abonnement", "viewPricingLink": "Zie prijzen, functies en limieten", "tiers": { "starter": { "title": "Beginner", "description": "Enterprise functies, 25 gebruikers, 25 sites en community ondersteuning." }, "scale": { "title": "Schaal", "description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning." } }, "personalUseOnly": "Alleen persoonlijk gebruik (gratis licentie - geen afrekenen)", "buttons": { "continueToCheckout": "Doorgaan naar afrekenen" }, "toasts": { "checkoutError": { "title": "Fout bij afrekenen", "description": "Kan de afhandeling niet starten. Probeer het opnieuw." } } }, "priority": "Prioriteit", "priorityDescription": "routes met hogere prioriteit worden eerst geëvalueerd. Prioriteit = 100 betekent automatisch bestellen (systeem beslist de). Gebruik een ander nummer om handmatige prioriteit af te dwingen.", "instanceName": "Naam instantie", "pathMatchModalTitle": "Configureren van overeenkomende pad", "pathMatchModalDescription": "Stel in hoe inkomende verzoeken moeten worden gekoppeld aan hun pad.", "pathMatchType": "Wedstrijd Type", "pathMatchPrefix": "Voorvoegsel", "pathMatchExact": "Exacte", "pathMatchRegex": "Regex", "pathMatchValue": "Pad waarde", "clear": "Verwijderen", "saveChanges": "Wijzigingen opslaan", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/Pad", "pathMatchPrefixHelp": "Voorbeeld: /api komt overeen met /api, /api/gebruikers, etc.", "pathMatchExactHelp": "Voorbeeld: /api matcht alleen /api", "pathMatchRegexHelp": "Voorbeeld: ^/api/.* komt overeen met /api/any", "pathRewriteModalTitle": "Configureer pad herschrijven", "pathRewriteModalDescription": "Verander het overeenstemmende pad voor het doorsturen naar het doel.", "pathRewriteType": "Herschrijf Type", "pathRewritePrefixOption": "Voorvoegsel - prefix vervangen", "pathRewriteExactOption": "Exacte - Vervang het gehele pad", "pathRewriteRegexOption": "Regex - Patroon vervanging", "pathRewriteStripPrefixOption": "Voorvoegsel verwijderen - Verwijder voorvoegsel", "pathRewriteValue": "Herschrijf Waarde", "pathRewriteRegexPlaceholder": "/nieuw/$1", "pathRewriteDefaultPlaceholder": "/nieuwe-pad", "pathRewritePrefixHelp": "Vervang de overeenkomstige prefix door deze waarde", "pathRewriteExactHelp": "Vervang het hele pad met deze waarde wanneer het pad precies overeenkomt", "pathRewriteRegexHelp": "Gebruik capturegroepen zoals $1, $2 voor vervanging", "pathRewriteStripPrefixHelp": "Laat leeg om de voorvoegsel te strippen of geef een nieuw voorvoegsel", "pathRewritePrefix": "Voorvoegsel", "pathRewriteExact": "Exacte", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Verwijder", "pathRewriteStripLabel": "strip", "sidebarEnableEnterpriseLicense": "Activeer Enterprise Licentie", "cannotbeUndone": "Dit kan niet ongedaan worden gemaakt.", "toConfirm": "om te bevestigen.", "deleteClientQuestion": "Weet u zeker dat u de client van de site en organisatie wilt verwijderen?", "clientMessageRemove": "Eenmaal verwijderd, kan de client geen verbinding meer maken met de site.", "sidebarLogs": "Logboeken", "request": "Aanvragen", "requests": "Verzoeken", "logs": "Logboeken", "logsSettingsDescription": "Controleer logs verzameld van deze organisatie", "searchLogs": "Logboeken zoeken...", "action": "actie", "actor": "Acteur", "timestamp": "Artikeldatering", "accessLogs": "Toegang tot logboek", "exportCsv": "Exporteren als CSV", "exportError": "Onbekende fout bij exporteren naar CSV", "exportCsvTooltip": "Binnen tijdsbereik", "actorId": "Acteur ID", "allowedByRule": "Toegestaan door regel", "allowedNoAuth": "Toegestaan geen authenticatie", "validAccessToken": "Geldige toegangstoken", "validHeaderAuth": "Valid header auth", "validPincode": "Valid Pincode", "validPassword": "Geldig wachtwoord", "validEmail": "Valid email", "validSSO": "Valid SSO", "resourceBlocked": "Bron geblokkeerd", "droppedByRule": "Achtergelaten door regel", "noSessions": "Geen sessies", "temporaryRequestToken": "Tijdelijk verzoek token", "noMoreAuthMethods": "No Valid Auth", "ip": "IP-adres", "reason": "Reden", "requestLogs": "Logboeken aanvragen", "requestAnalytics": "Analytics opvragen", "host": "Hostnaam", "location": "Locatie", "actionLogs": "Actie logs", "sidebarLogsRequest": "Logboeken aanvragen", "sidebarLogsAccess": "Toegang tot logboek", "sidebarLogsAction": "Actie logs", "logRetention": "Log bewaring", "logRetentionDescription": "Beheren hoe lang verschillende soorten logs bewaard worden voor deze organisatie of schakel ze uit", "requestLogsDescription": "Bekijk gedetailleerde verzoeklogboeken voor resources in deze organisatie", "requestAnalyticsDescription": "Bekijk gedetailleerde request analytics voor resources in deze organisatie", "logRetentionRequestLabel": "Logboekbewaring aanvragen", "logRetentionRequestDescription": "Hoe lang de aanvraaglogboeken te behouden", "logRetentionAccessLabel": "Toegang logboek bewaring", "logRetentionAccessDescription": "Hoe lang de toegangslogboeken behouden blijven", "logRetentionActionLabel": "Actie log bewaring", "logRetentionActionDescription": "Hoe lang de action logs behouden moeten blijven", "logRetentionDisabled": "Uitgeschakeld", "logRetention3Days": "3 dagen", "logRetention7Days": "7 dagen", "logRetention14Days": "14 dagen", "logRetention30Days": "30 dagen", "logRetention90Days": "90 dagen", "logRetentionForever": "Voor altijd", "logRetentionEndOfFollowingYear": "Einde van volgend jaar", "actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie", "accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken", "licenseRequiredToUse": "Een Enterprise Edition licentie is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in Pangolin Cloud.", "ossEnterpriseEditionRequired": "De Enterprise Edition is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in Pangolin Cloud.", "certResolver": "Certificaat Resolver", "certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.", "selectCertResolver": "Certificaat Resolver selecteren", "enterCustomResolver": "Aangepaste Oplossing invoeren", "preferWildcardCert": "Bij voorkeur Wildcard Certificaat", "unverified": "Ongeverifieerd", "domainSetting": "Domein instellingen", "domainSettingDescription": "Configureer instellingen voor het domein", "preferWildcardCertDescription": "Probeer een wildcardcertificaat te genereren (vereist een correct geconfigureerde certificaatoplosser).", "recordName": "Record Naam", "auto": "Automatisch", "TTL": "TTL", "howToAddRecords": "Hoe voeg ik Records toe", "dnsRecord": "DNS Records", "required": "vereist", "domainSettingsUpdated": "Domeininstellingen succesvol bijgewerkt", "orgOrDomainIdMissing": "Organisatie of domein ID ontbreekt", "loadingDNSRecords": "DNS-records laden...", "olmUpdateAvailableInfo": "Er is een bijgewerkte versie van Olm beschikbaar. Update alstublieft naar de nieuwste versie voor de beste ervaring.", "client": "Klant", "proxyProtocol": "Proxy Protocol Instellingen", "proxyProtocolDescription": "Proxyprotocol configureren om de IP-adressen van de client voor TCP-diensten te bewaren.", "enableProxyProtocol": "Proxy Protocol inschakelen", "proxyProtocolInfo": "Behoud IP adressen van de client voor TCP backends", "proxyProtocolVersion": "Proxy Protocol Versie", "version1": " Versie 1 (Aanbevolen)", "version2": "Versie 2", "versionDescription": "Versie 1 is text-based en breed ondersteund. Versie 2 is binair en efficiënter maar minder compatibel.", "warning": "Waarschuwing", "proxyProtocolWarning": "De backend applicatie moet worden geconfigureerd om Proxy Protocol verbindingen te accepteren. Als je backend geen Proxy Protocol ondersteunt, zal het inschakelen van dit alle verbindingen verbreken, dus schakel dit alleen in als je weet wat je doet. Zorg ervoor dat je je backend configureert om Proxy Protocol headers van Traefik.", "restarting": "Herstarten...", "manual": "Handleiding", "messageSupport": "Bericht ondersteuning", "supportNotAvailableTitle": "Ondersteuning niet beschikbaar", "supportNotAvailableDescription": "Ondersteuning is momenteel niet beschikbaar. U kunt een e-mail sturen naar support@pangolin.net.", "supportRequestSentTitle": "Ondersteuningsverzoek verzonden", "supportRequestSentDescription": "Uw bericht is succesvol verzonden.", "supportRequestFailedTitle": "Kon aanvraag niet verzenden", "supportRequestFailedDescription": "Er is een fout opgetreden tijdens het verzenden van uw supportverzoek.", "supportSubjectRequired": "Onderwerp is vereist", "supportSubjectMaxLength": "Onderwerp moet 255 tekens of minder lang zijn", "supportMessageRequired": "Bericht is vereist", "supportReplyTo": "Antwoord aan", "supportSubject": "Onderwerp", "supportSubjectPlaceholder": "Onderwerp invoeren", "supportMessage": "bericht", "supportMessagePlaceholder": "Voer uw bericht in", "supportSending": "Verzenden...", "supportSend": "Verzenden", "supportMessageSent": "Bericht verzonden!", "supportWillContact": "We nemen binnenkort contact met u op!", "selectLogRetention": "Selecteer log retentie", "terms": "Voorwaarden", "privacy": "Privacy", "security": "Beveiliging", "docs": "Documentatie", "deviceActivation": "Apparaat activatie", "deviceCodeInvalidFormat": "Code moet 9 tekens bevatten (bijv. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Ongeldige of verlopen code", "deviceCodeVerifyFailed": "Apparaatcode verifiëren mislukt", "deviceCodeValidating": "Apparaatcode valideren...", "deviceCodeVerifying": "Apparaatmachtiging verifiëren...", "signedInAs": "Ingelogd als", "deviceCodeEnterPrompt": "Voer de op het apparaat weergegeven code in", "continue": "Doorgaan", "deviceUnknownLocation": "Onbekende locatie", "deviceAuthorizationRequested": "Deze autorisatie is aangevraagd bij {location} op {date}. Vertrouw je dit apparaat omdat het toegang tot het account zal krijgen.", "deviceLabel": "Apparaat: {deviceName}", "deviceWantsAccess": "wil toegang tot je account", "deviceExistingAccess": "Bestaande toegang:", "deviceFullAccess": "Volledige toegang tot uw account", "deviceOrganizationsAccess": "Toegang tot alle organisaties waar uw account toegang tot heeft", "deviceAuthorize": "Autoriseer {applicationName}", "deviceConnected": "Apparaat verbonden!", "deviceAuthorizedMessage": "Apparaat is gemachtigd om toegang te krijgen tot je account. Ga terug naar de client applicatie.", "pangolinCloud": "Pangoline Cloud", "viewDevices": "Bekijk apparaten", "viewDevicesDescription": "Beheer uw aangesloten apparaten", "noDevices": "Geen apparaten gevonden", "dateCreated": "Datum aangemaakt", "unnamedDevice": "Naamloos apparaat", "deviceQuestionRemove": "Weet u zeker dat u dit apparaat wilt verwijderen?", "deviceMessageRemove": "Deze actie kan niet ongedaan worden gemaakt.", "deviceDeleteConfirm": "Apparaat verwijderen", "deleteDevice": "Apparaat verwijderen", "errorLoadingDevices": "Fout bij laden van apparaten", "failedToLoadDevices": "Fout bij het laden van apparaten", "deviceDeleted": "Apparaat verwijderd", "deviceDeletedDescription": "Het apparaat is succesvol verwijderd.", "errorDeletingDevice": "Fout bij verwijderen apparaat", "failedToDeleteDevice": "Verwijderen van apparaat mislukt", "showColumns": "Kolommen weergeven", "hideColumns": "Kolommen verbergen", "columnVisibility": "Zichtbaarheid kolommen", "toggleColumn": "{columnName} kolom in-/uitschakelen", "allColumns": "Alle kolommen", "defaultColumns": "Standaard Kolommen", "customizeView": "Weergave aanpassen", "viewOptions": "Bekijk opties", "selectAll": "Alles selecteren", "selectNone": "Niets selecteren", "selectedResources": "Geselecteerde bronnen", "enableSelected": "Selectie inschakelen", "disableSelected": "Selectie uitschakelen", "checkSelectedStatus": "Controleer de status van de geselecteerde", "clients": "Clienten", "accessClientSelect": "Selecteer machine-clients", "resourceClientDescription": "Machine clients die toegang hebben tot deze bron", "regenerate": "Hergenereren", "credentials": "Aanmeldgegevens", "savecredentials": "Referenties opslaan", "regenerateCredentialsButton": "Referenties opnieuw genereren", "regenerateCredentials": "Referenties opnieuw genereren", "generatedcredentials": "Gegenereerde referenties", "copyandsavethesecredentials": "Kopieer en bewaar deze inloggegevens", "copyandsavethesecredentialsdescription": "Deze referenties worden niet meer getoond nadat u deze pagina verlaat. Sla ze nu veilig op.", "credentialsSaved": "Referenties opgeslagen", "credentialsSavedDescription": "Referenties werden met succes opnieuw gegenereerd en opgeslagen.", "credentialsSaveError": "Fout bij opslaan referenties", "credentialsSaveErrorDescription": "Er is een fout opgetreden tijdens het opnieuw genereren en opslaan van de inloggegevens.", "regenerateCredentialsWarning": "Het opnieuw genereren van inloggegevens zal de vorige ongeldig maken en een slechte verbinding veroorzaken. Zorg ervoor dat u alle configuraties die deze inloggegevens gebruiken bijwerkt.", "confirm": "Bevestigen", "regenerateCredentialsConfirmation": "Weet u zeker dat u de inloggegevens opnieuw wilt genereren?", "endpoint": "Endpoint", "Id": "Id", "SecretKey": "Geheime sleutel", "niceId": "Leuk ID", "niceIdUpdated": "Leuke ID bijgewerkt", "niceIdUpdatedSuccessfully": "Nice ID Updated Successfully", "niceIdUpdateError": "Fout bij bijwerken ID Nice", "niceIdUpdateErrorDescription": "Fout opgetreden tijdens het bijwerken van de ID van Nice.", "niceIdCannotBeEmpty": "Nice ID mag niet leeg zijn", "enterIdentifier": "ID invoeren", "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Niet u? Gebruik een ander account.", "deviceLoginDeviceRequestingAccessToAccount": "Een apparaat vraagt om toegang tot dit account.", "loginSelectAuthenticationMethod": "Selecteer een verificatiemethode om door te gaan.", "noData": "Geen gegevens", "machineClients": "Machine Clienten", "install": "Installeren", "run": "Uitvoeren", "clientNameDescription": "De weergavenaam van de client die later gewijzigd kan worden.", "clientAddress": "Klant adres (Geavanceerd)", "setupFailedToFetchSubnet": "Kan standaard subnet niet ophalen", "setupSubnetAdvanced": "Subnet (Geavanceerd)", "setupSubnetDescription": "Het subnet van het interne netwerk van deze organisatie.", "setupUtilitySubnet": "Hulpprogrammasubnet (Geavanceerd)", "setupUtilitySubnetDescription": "Het subnet voor de aliasadressen en DNS-server van deze organisatie.", "siteRegenerateAndDisconnect": "Hergenereer en verbreek verbinding", "siteRegenerateAndDisconnectConfirmation": "Weet u zeker dat u de inloggegevens opnieuw wilt genereren en de verbinding met deze website wilt verbreken?", "siteRegenerateAndDisconnectWarning": "Dit zal de inloggegevens opnieuw genereren en onmiddellijk de site ontkoppelen. De site zal opnieuw moeten worden gestart met de nieuwe inloggegevens.", "siteRegenerateCredentialsConfirmation": "Weet u zeker dat u de referenties voor deze site opnieuw wilt genereren?", "siteRegenerateCredentialsWarning": "Dit zal de inloggegevens opnieuw genereren. De site zal verbonden blijven totdat u het handmatig herstart en de nieuwe inloggegevens gebruikt.", "clientRegenerateAndDisconnect": "Hergenereer en verbreek verbinding", "clientRegenerateAndDisconnectConfirmation": "Weet u zeker dat u de inloggegevens opnieuw wilt genereren en de verbinding met deze client wilt verbreken?", "clientRegenerateAndDisconnectWarning": "Dit zal de inloggegevens opnieuw genereren en onmiddellijk de verbinding verbreken. De client zal opnieuw moeten worden gestart met de nieuwe inloggegevens.", "clientRegenerateCredentialsConfirmation": "Weet u zeker dat u de referenties voor deze client opnieuw wilt genereren?", "clientRegenerateCredentialsWarning": "Dit zal de inloggegevens opnieuw genereren. De client zal verbonden blijven totdat u het handmatig herstart en de nieuwe inloggegevens gebruikt.", "remoteExitNodeRegenerateAndDisconnect": "Hergenereer en verbreek verbinding", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Weet u zeker dat u de inloggegevens opnieuw wilt genereren en deze afstandsbediening wilt loskoppelen?", "remoteExitNodeRegenerateAndDisconnectWarning": "Dit zal de referenties regenereren en onmiddellijk de externe exit node ontkoppelen. Het externe exit node zal opnieuw moeten worden gestart met de nieuwe referenties.", "remoteExitNodeRegenerateCredentialsConfirmation": "Weet u zeker dat u de referenties voor deze externe exit node opnieuw wilt genereren?", "remoteExitNodeRegenerateCredentialsWarning": "Dit zal de referenties opnieuw genereren. De remote exit node zal verbonden blijven totdat u deze handmatig herstart en de nieuwe referenties gebruikt.", "agent": "Agent", "personalUseOnly": "Alleen voor persoonlijk gebruik", "loginPageLicenseWatermark": "Deze instantie is alleen gelicentieerd voor persoonlijk gebruik.", "instanceIsUnlicensed": "Deze instantie is niet gelicentieerd.", "portRestrictions": "Poortbeperkingen", "allPorts": "Alles", "custom": "Aangepast", "allPortsAllowed": "Alle poorten toegestaan", "allPortsBlocked": "Alle poorten geblokkeerd", "tcpPortsDescription": "Geef op welke TCP-poorten zijn toegestaan voor deze bron. Gebruik '*' voor alle poorten, laat leeg om alles te blokkeren of voer een komma-gescheiden lijst van poorten en reeksen in (bijv. 80,443,8000-9000).", "udpPortsDescription": "Geef op welke UDP-poorten zijn toegestaan voor deze bron. Gebruik '*' voor alle poorten, laat leeg om alles te blokkeren of voer een komma-gescheiden lijst van poorten en reeksen in (bijv. 53,123,500-600).", "organizationLoginPageTitle": "Organisatie-inlogpagina", "organizationLoginPageDescription": "Pas de inlogpagina voor deze organisatie aan", "resourceLoginPageTitle": "Inlogpagina voor bronnen", "resourceLoginPageDescription": "Pas de inlogpagina aan voor individuele bronnen", "enterConfirmation": "Bevestiging invoeren", "blueprintViewDetails": "Details", "defaultIdentityProvider": "Standaard Identiteitsprovider", "defaultIdentityProviderDescription": "Wanneer een standaard identity provider is geselecteerd, zal de gebruiker automatisch worden doorgestuurd naar de provider voor authenticatie.", "editInternalResourceDialogNetworkSettings": "Netwerkinstellingen", "editInternalResourceDialogAccessPolicy": "Toegangsbeleid", "editInternalResourceDialogAddRoles": "Rollen toevoegen", "editInternalResourceDialogAddUsers": "Gebruikers toevoegen", "editInternalResourceDialogAddClients": "Clienten toevoegen", "editInternalResourceDialogDestinationLabel": "Bestemming", "editInternalResourceDialogDestinationDescription": "Specificeer het bestemmingsadres voor de interne bron. Dit kan een hostnaam, IP-adres of CIDR-bereik zijn, afhankelijk van de geselecteerde modus. Stel optioneel een interne DNS-alias in voor eenvoudigere identificatie.", "editInternalResourceDialogPortRestrictionsDescription": "Beperk toegang tot specifieke TCP/UDP-poorten of sta alle poorten toe/blokkeer.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Toegangs controle", "editInternalResourceDialogAccessControlDescription": "Beheer welke rollen, gebruikers en machineclients toegang hebben tot deze bron wanneer ze zijn verbonden. Beheerders hebben altijd toegang.", "editInternalResourceDialogPortRangeValidationError": "Poortbereik moet \"*\" zijn voor alle poorten, of een komma-gescheiden lijst van poorten en bereiken (bijv. \"80,443,8000-9000\"). Poorten moeten tussen 1 en 65535 zijn.", "internalResourceAuthDaemonStrategy": "SSH Auth Daemon locatie", "internalResourceAuthDaemonStrategyDescription": "Kies waar de SSH authenticatie daemon wordt uitgevoerd: op de website (Newt) of op een externe host.", "internalResourceAuthDaemonDescription": "De SSH authenticatie daemon zorgt voor SSH sleutelondertekening en PAM authenticatie voor deze resource. Kies of het wordt uitgevoerd op de website (Nieuw) of op een afzonderlijke externe host. Zie de documentatie voor meer.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Selecteer strategie", "internalResourceAuthDaemonStrategyLabel": "Locatie", "internalResourceAuthDaemonSite": "In de site", "internalResourceAuthDaemonSiteDescription": "Auth daemon draait op de site (Newt).", "internalResourceAuthDaemonRemote": "Externe host", "internalResourceAuthDaemonRemoteDescription": "Authenticatiedaemon draait op een host die niet de site is.", "internalResourceAuthDaemonPort": "Daemon poort (optioneel)", "orgAuthWhatsThis": "Waar kan ik mijn organisatie-ID vinden?", "learnMore": "Meer informatie", "backToHome": "Ga terug naar startpagina", "needToSignInToOrg": "Moet u de identiteit provider van uw organisatie gebruiken?", "maintenanceMode": "Onderhoudsmodus", "maintenanceModeDescription": "Toon een onderhoudspagina aan bezoekers", "maintenanceModeType": "Type onderhoudsmodus", "showMaintenancePage": "Toon een onderhoudspagina aan bezoekers", "enableMaintenanceMode": "Onderhoudsmodus inschakelen", "automatic": "Automatisch", "automaticModeDescription": " Toon onderhoudspagina alleen wanneer alle back-enddoelen niet beschikbaar zijn of ongezond zijn. Jouw bron blijft normaal functioneren zolang er tenminste één doel gezond is.", "forced": "Geforceerd", "forcedModeDescription": "Toon altijd de onderhoudspagina ongeacht de gezondheid van de backend. Gebruik dit voor gepland onderhoud wanneer je alle toegang wilt voorkomen.", "warning:": "Waarschuwing:", "forcedeModeWarning": "Al het verkeer wordt naar de onderhoudspagina geleid. Jouw back-endbronnen ontvangen geen verzoeken.", "pageTitle": "Paginatitel", "pageTitleDescription": "De hoofdkop die op de onderhoudspagina wordt weergegeven", "maintenancePageMessage": "Onderhoudsbericht", "maintenancePageMessagePlaceholder": "We keren snel terug! Onze site ondergaat momenteel gepland onderhoud.", "maintenancePageMessageDescription": "Gedetailleerd bericht dat het onderhoud uitlegt", "maintenancePageTimeTitle": "Geschatte voltooiingstijd (optioneel)", "maintenanceTime": "bijv. 2 uur, 1 nov om 17:00", "maintenanceEstimatedTimeDescription": "Wanneer u verwacht dat het onderhoud voltooid is", "editDomain": "Domein bewerken", "editDomainDescription": "Selecteer een domein voor jouw hulpbron", "maintenanceModeDisabledTooltip": "Deze functie vereist een geldige licentie om in te schakelen.", "maintenanceScreenTitle": "Dienst tijdelijk niet beschikbaar", "maintenanceScreenMessage": "We hebben momenteel technische problemen. Probeer het later opnieuw.", "maintenanceScreenEstimatedCompletion": "Geschatte voltooiing:", "createInternalResourceDialogDestinationRequired": "Bestemming is vereist", "available": "Beschikbaar", "archived": "Gearchiveerd", "noArchivedDevices": "Geen gearchiveerde apparaten gevonden", "deviceArchived": "Apparaat gearchiveerd", "deviceArchivedDescription": "Het apparaat is met succes gearchiveerd.", "errorArchivingDevice": "Fout bij archiveren apparaat", "failedToArchiveDevice": "Kan apparaat niet archiveren", "deviceQuestionArchive": "Weet u zeker dat u dit apparaat wilt archiveren?", "deviceMessageArchive": "Het apparaat wordt gearchiveerd en verwijderd uit de lijst met actieve apparaten.", "deviceArchiveConfirm": "Archiveer apparaat", "archiveDevice": "Archiveer apparaat", "archive": "Archief", "deviceUnarchived": "Apparaat niet gearchiveerd", "deviceUnarchivedDescription": "Het apparaat is met succes gedearchiveerd.", "errorUnarchivingDevice": "Fout bij dearchiveren van apparaat", "failedToUnarchiveDevice": "Apparaat dearchiveren mislukt", "unarchive": "Dearchiveren", "archiveClient": "Archiveer client", "archiveClientQuestion": "Weet u zeker dat u deze client wilt archiveren?", "archiveClientMessage": "De klant zal worden gearchiveerd en verwijderd uit de lijst met actieve cliënten.", "archiveClientConfirm": "Archiveer client", "blockClient": "Blokkeer klant", "blockClientQuestion": "Weet u zeker dat u deze cliënt wilt blokkeren?", "blockClientMessage": "Het apparaat zal worden gedwongen de verbinding te verbreken als het momenteel is verbonden. U kunt het apparaat later deblokkeren.", "blockClientConfirm": "Blokkeer klant", "active": "actief", "usernameOrEmail": "Gebruikersnaam of e-mailadres", "selectYourOrganization": "Selecteer uw organisatie", "signInTo": "Log in op", "signInWithPassword": "Ga verder met wachtwoord", "noAuthMethodsAvailable": "Geen verificatiemethoden beschikbaar voor deze organisatie.", "enterPassword": "Voer je wachtwoord in", "enterMfaCode": "Voer de code van je authenticator-app in", "securityKeyRequired": "Gebruik uw beveiligingssleutel om in te loggen.", "needToUseAnotherAccount": "Wilt u een ander account gebruiken?", "loginLegalDisclaimer": "Door op de knoppen hieronder te klikken, erken je dat je gelezen en begrepen hebt en ga akkoord met de Gebruiksvoorwaarden en Privacybeleid.", "termsOfService": "Algemene gebruiksvoorwaarden", "privacyPolicy": "Privacy Beleid", "userNotFoundWithUsername": "Geen gebruiker gevonden met die gebruikersnaam.", "verify": "Verifiëren", "signIn": "Log in", "forgotPassword": "Wachtwoord vergeten?", "orgSignInTip": "Als u eerder bent ingelogd, kunt u uw gebruikersnaam of e-mail hierboven invoeren om in plaats daarvan te verifiëren met de identiteitsprovider van uw organisatie! Het is makkelijk!", "continueAnyway": "Toch doorgaan", "dontShowAgain": "Niet meer weergeven", "orgSignInNotice": "Wist u dat?", "signupOrgNotice": "Proberen je aan te melden?", "signupOrgTip": "Probeert u zich aan te melden via de identiteitsprovider van uw organisatie?", "signupOrgLink": "Log in of meld je aan bij je organisatie", "verifyEmailLogInWithDifferentAccount": "Gebruik een ander account", "logIn": "Log in", "deviceInformation": "Apparaat informatie", "deviceInformationDescription": "Informatie over het apparaat en de agent", "deviceSecurity": "Apparaat beveiliging", "deviceSecurityDescription": "Apparaat beveiligingsinformatie", "platform": "Platform", "macosVersion": "macOS versie", "windowsVersion": "Windows versie", "iosVersion": "iOS versie", "androidVersion": "Android versie", "osVersion": "OS versie", "kernelVersion": "Kernel versie", "deviceModel": "Apparaat model", "serialNumber": "Serienummer", "hostname": "Hostname", "firstSeen": "Eerst gezien", "lastSeen": "Laatst gezien op", "biometricsEnabled": "Biometrie ingeschakeld", "diskEncrypted": "Schijf versleuteld", "firewallEnabled": "Firewall ingeschakeld", "autoUpdatesEnabled": "Auto Updates Ingeschakeld", "tpmAvailable": "TPM beschikbaar", "windowsAntivirusEnabled": "Antivirus ingeschakeld", "macosSipEnabled": "Systeemintegriteitsbescherming (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Firewall Verberg Modus", "linuxAppArmorEnabled": "Appharnas", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "Apparaatinformatie en -instellingen bekijken", "devicePendingApprovalDescription": "Dit apparaat wacht op goedkeuring", "deviceBlockedDescription": "Dit apparaat is momenteel geblokkeerd. Het kan geen verbinding maken met bronnen tenzij het wordt gedeblokkeerd.", "unblockClient": "Deblokkeer client", "unblockClientDescription": "Het apparaat is gedeblokkeerd", "unarchiveClient": "Dearchiveer client", "unarchiveClientDescription": "Het apparaat is gedearchiveerd", "block": "Blokkeren", "unblock": "Deblokkeer", "deviceActions": "Apparaat Acties", "deviceActionsDescription": "Apparaatstatus en toegang beheren", "devicePendingApprovalBannerDescription": "Dit apparaat wacht op goedkeuring. Het zal niet in staat zijn verbinding te maken met bronnen totdat het is goedgekeurd.", "connected": "Verbonden", "disconnected": "Losgekoppeld", "approvalsEmptyStateTitle": "Apparaat goedkeuringen niet ingeschakeld", "approvalsEmptyStateDescription": "Apparaatgoedkeuringen voor rollen inschakelen om goedkeuring van de beheerder te vereisen voordat gebruikers nieuwe apparaten kunnen koppelen.", "approvalsEmptyStateStep1Title": "Ga naar rollen", "approvalsEmptyStateStep1Description": "Navigeer naar de rolinstellingen van uw organisatie om apparaatgoedkeuringen te configureren.", "approvalsEmptyStateStep2Title": "Toestel goedkeuringen inschakelen", "approvalsEmptyStateStep2Description": "Bewerk een rol en schakel de optie 'Vereist Apparaat Goedkeuringen' in. Gebruikers met deze rol hebben admin goedkeuring nodig voor nieuwe apparaten.", "approvalsEmptyStatePreviewDescription": "Voorbeeld: Indien ingeschakeld, zullen in afwachting van apparaatverzoeken hier verschijnen om te beoordelen", "approvalsEmptyStateButtonText": "Rollen beheren" } ================================================ FILE: messages/pl-PL.json ================================================ { "setupCreate": "Utwórz organizację, witrynę i zasoby", "headerAuthCompatibilityInfo": "Włącz to, aby wymusić odpowiedź Unauthorized 401, gdy brakuje tokena uwierzytelniania. Jest to wymagane dla przeglądarek lub określonych bibliotek HTTP, które nie wysyłają poświadczeń bez wyzwania serwera.", "headerAuthCompatibility": "Rozszerzona kompatybilność", "setupNewOrg": "Nowa organizacja", "setupCreateOrg": "Utwórz organizację", "setupCreateResources": "Utwórz Zasoby", "setupOrgName": "Nazwa organizacji", "orgDisplayName": "To jest wyświetlana nazwa organizacji.", "orgId": "Identyfikator organizacji", "setupIdentifierMessage": "Jest to unikalny identyfikator organizacji.", "setupErrorIdentifier": "Identyfikator organizacji jest już zajęty. Wybierz inny.", "componentsErrorNoMemberCreate": "Nie jesteś obecnie członkiem żadnej organizacji. Aby rozpocząć, utwórz organizację.", "componentsErrorNoMember": "Nie jesteś obecnie członkiem żadnej organizacji.", "welcome": "Witaj w Pangolinie", "welcomeTo": "Witaj w", "componentsCreateOrg": "Utwórz organizację", "componentsMember": "Jesteś członkiem {count, plural, =0 {żadna organizacja} one {jedna organizacja} few {# organizacje} many {# organizacji} other {# organizacji}}.", "componentsInvalidKey": "Wykryto nieprawidłowe lub wygasłe klucze licencyjne. Postępuj zgodnie z warunkami licencji, aby kontynuować korzystanie ze wszystkich funkcji.", "dismiss": "Odrzuć", "subscriptionViolationMessage": "Nie masz ograniczeń dla aktualnego planu. Popraw problem poprzez usunięcie stron, użytkowników lub innych zasobów, aby pozostać w swoim planie.", "subscriptionViolationViewBilling": "Zobacz rozliczenie", "componentsLicenseViolation": "Naruszenie licencji: Ten serwer używa stron {usedSites} , które przekraczają limit licencyjny stron {maxSites} . Postępuj zgodnie z warunkami licencji, aby kontynuować korzystanie ze wszystkich funkcji.", "componentsSupporterMessage": "Dziękujemy za wsparcie Pangolina jako {tier}!", "inviteErrorNotValid": "Przykro nam, ale wygląda na to, że zaproszenie, do którego próbujesz uzyskać dostęp, nie zostało zaakceptowane lub jest już nieważne.", "inviteErrorUser": "Przykro nam, ale wygląda na to, że zaproszenie, do którego próbujesz uzyskać dostęp, nie jest dla tego użytkownika.", "inviteLoginUser": "Upewnij się, że jesteś zalogowany jako właściwy użytkownik.", "inviteErrorNoUser": "Przykro nam, ale wygląda na to, że zaproszenie, do którego próbujesz uzyskać dostęp, nie jest dla użytkownika, który istnieje.", "inviteCreateUser": "Proszę najpierw utworzyć konto.", "goHome": "Przejdź do strony głównej", "inviteLogInOtherUser": "Zaloguj się jako inny użytkownik", "createAnAccount": "Utwórz konto", "inviteNotAccepted": "Zaproszenie nie zaakceptowane", "authCreateAccount": "Utwórz konto, aby rozpocząć", "authNoAccount": "Nie masz konta?", "email": "E-mail", "password": "Hasło", "confirmPassword": "Potwierdź hasło", "createAccount": "Utwórz konto", "viewSettings": "Pokaż ustawienia", "delete": "Usuń", "name": "Nazwa", "online": "Dostępny", "offline": "Offline", "site": "Witryna", "dataIn": "Dane Przychodzące", "dataOut": "Dane Wychodzące", "connectionType": "Typ połączenia", "tunnelType": "Typ tunelu", "local": "Lokalny", "edit": "Edytuj", "siteConfirmDelete": "Potwierdź usunięcie witryny", "siteDelete": "Usuń witrynę", "siteMessageRemove": "Po usunięciu witryna nie będzie już dostępna. Wszystkie cele związane z witryną zostaną również usunięte.", "siteQuestionRemove": "Czy na pewno chcesz usunąć witrynę z organizacji?", "siteManageSites": "Zarządzaj stronami", "siteDescription": "Tworzenie stron i zarządzanie nimi, aby włączyć połączenia z prywatnymi sieciami", "sitesBannerTitle": "Połącz dowolną sieć", "sitesBannerDescription": "Witryna to połączenie z siecią zdalną, które umożliwia Pangolinowi zapewnienie dostępu do zasobów, publicznych lub prywatnych, użytkownikom w dowolnym miejscu. Zainstaluj łącznik sieci w witrynie (Newt) w dowolnym miejscu, w którym możesz uruchomić binarkę lub kontener, aby ustanowić połączenie.", "sitesBannerButtonText": "Zainstaluj witrynę", "approvalsBannerTitle": "Zatwierdź lub odmów dostępu do urządzenia", "approvalsBannerDescription": "Przejrzyj i zatwierdzaj lub odmawiaj użytkownikom dostępu do urządzenia. Gdy wymagane jest zatwierdzenie urządzenia, użytkownicy muszą uzyskać zatwierdzenie administratora, zanim ich urządzenia będą mogły połączyć się z zasobami Twojej organizacji.", "approvalsBannerButtonText": "Dowiedz się więcej", "siteCreate": "Utwórz witrynę", "siteCreateDescription2": "Wykonaj poniższe kroki, aby utworzyć i połączyć nową witrynę", "siteCreateDescription": "Utwórz nową witrynę, aby rozpocząć łączenie zasobów", "close": "Zamknij", "siteErrorCreate": "Błąd podczas tworzenia witryny", "siteErrorCreateKeyPair": "Nie znaleziono pary kluczy lub domyślnych ustawień witryny", "siteErrorCreateDefaults": "Nie znaleziono domyślnych ustawień witryny", "method": "Metoda", "siteMethodDescription": "W ten sposób ujawnisz połączenia.", "siteLearnNewt": "Dowiedz się, jak zainstalować Newt w systemie", "siteSeeConfigOnce": "Możesz zobaczyć konfigurację tylko raz.", "siteLoadWGConfig": "Ładowanie konfiguracji WireGuard...", "siteDocker": "Rozwiń o szczegóły wdrożenia Dockera", "toggle": "Przełącz", "dockerCompose": "Kompozytor dokujący", "dockerRun": "Uruchom Docker", "siteLearnLocal": "Lokalne witryny nie tunelują, dowiedz się więcej", "siteConfirmCopy": "Skopiowałem konfigurację", "searchSitesProgress": "Szukaj witryn...", "siteAdd": "Dodaj witrynę", "siteInstallNewt": "Zainstaluj Newt", "siteInstallNewtDescription": "Uruchom Newt w swoim systemie", "WgConfiguration": "Konfiguracja WireGuard", "WgConfigurationDescription": "Użyj następującej konfiguracji, aby połączyć się z siecią", "operatingSystem": "System operacyjny", "commands": "Polecenia", "recommended": "Rekomendowane", "siteNewtDescription": "Aby uzyskać najlepsze doświadczenia użytkownika, użyj Newt. Używa wewnętrznie WireGuard i pozwala na przekierowanie twoich prywatnych zasobów przez ich adres LAN w sieci prywatnej z panelu Pangolin.", "siteRunsInDocker": "Uruchamia w Dockerze", "siteRunsInShell": "Uruchamia w powłoce na macOS, Linux i Windows", "siteErrorDelete": "Błąd podczas usuwania witryny", "siteErrorUpdate": "Nie udało się zaktualizować witryny", "siteErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji witryny.", "siteUpdated": "Strona zaktualizowana", "siteUpdatedDescription": "Strona została zaktualizowana.", "siteGeneralDescription": "Skonfiguruj ustawienia ogólne dla tej witryny", "siteSettingDescription": "Skonfiguruj ustawienia na stronie", "siteSetting": "Ustawienia {siteName}", "siteNewtTunnel": "Newt Site (Rekomendowane)", "siteNewtTunnelDescription": "Najprostszy sposób na stworzenie punktu wejścia w sieci. Nie ma dodatkowej konfiguracji.", "siteWg": "Podstawowy WireGuard", "siteWgDescription": "Użyj dowolnego klienta WireGuard do utworzenia tunelu. Wymagana jest ręczna konfiguracja NAT.", "siteWgDescriptionSaas": "Użyj dowolnego klienta WireGuard do utworzenia tunelu. Wymagana ręczna konfiguracja NAT. DZIAŁA TYLKO NA SAMODZIELNIE HOSTOWANYCH WĘZŁACH", "siteLocalDescription": "Tylko lokalne zasoby. Brak tunelu.", "siteLocalDescriptionSaas": "Tylko zasoby lokalne. Brak tunelu. Dostępne tylko w węzłach zdalnych.", "siteSeeAll": "Zobacz wszystkie witryny", "siteTunnelDescription": "Określ jak chcesz połączyć się z witryną", "siteNewtCredentials": "Dane logowania", "siteNewtCredentialsDescription": "Oto jak witryna będzie uwierzytelniać się z serwerem", "remoteNodeCredentialsDescription": "Tak będzie działać uwierzytelnianie z serwerem dla zdalnego węzła", "siteCredentialsSave": "Zapisz dane logowania", "siteCredentialsSaveDescription": "Możesz to zobaczyć tylko raz. Upewnij się, że skopiuj je do bezpiecznego miejsca.", "siteInfo": "Informacje o witrynie", "status": "Status", "shareTitle": "Zarządzaj linkami udostępniania", "shareDescription": "Utwórz linki do współdzielenia, aby przyznać tymczasowy lub stały dostęp do zasobów proxy", "shareSearch": "Szukaj linków udostępnienia...", "shareCreate": "Utwórz link udostępniania", "shareErrorDelete": "Nie udało się usunąć linku", "shareErrorDeleteMessage": "Wystąpił błąd podczas usuwania linku", "shareDeleted": "Link usunięty", "shareDeletedDescription": "Link został usunięty", "shareTokenDescription": "Token dostępu może być przekazywany na dwa sposoby: jako parametr zapytania lub w nagłówkach żądania. Muszą być przekazywane z klienta na każde żądanie uwierzytelnionego dostępu.", "accessToken": "Token dostępu", "usageExamples": "Przykłady użycia", "tokenId": "Identyfikator tokena", "requestHeades": "Nagłówki żądania", "queryParameter": "Parametr zapytania", "importantNote": "Ważna uwaga", "shareImportantDescription": "Ze względów bezpieczeństwa zaleca się użycie nagłówków nad parametrami zapytania, jeśli to możliwe, ponieważ parametry zapytania mogą być zalogowane w dziennikach serwera lub historii przeglądarki.", "token": "Token", "shareTokenSecurety": "Zachowaj bezpieczny token dostępu. Nie udostępniaj go w publicznie dostępnych miejscach ani w kodzie po stronie klienta.", "shareErrorFetchResource": "Nie udało się pobrać zasobów", "shareErrorFetchResourceDescription": "Wystąpił błąd podczas pobierania zasobów", "shareErrorCreate": "Nie udało się utworzyć linku udostępniania", "shareErrorCreateDescription": "Wystąpił błąd podczas tworzenia linku udostępniania", "shareCreateDescription": "Każdy z tym linkiem może uzyskać dostęp do zasobu", "shareTitleOptional": "Tytuł (opcjonalnie)", "expireIn": "Wygasa za", "neverExpire": "Nigdy nie wygasa", "shareExpireDescription": "Czas wygaśnięcia to jak długo link będzie mógł być użyty i zapewni dostęp do zasobu. Po tym czasie link nie będzie już działał, a użytkownicy, którzy użyli tego linku, utracą dostęp do zasobu.", "shareSeeOnce": "Możesz zobaczyć ten link tylko raz. Pamiętaj, aby go skopiować.", "shareAccessHint": "Każdy z tym linkiem może uzyskać dostęp do zasobu. Podziel się nim ostrożnie.", "shareTokenUsage": "Zobacz użycie tokenu dostępu", "createLink": "Utwórz link", "resourcesNotFound": "Nie znaleziono zasobów", "resourceSearch": "Szukaj zasobów", "openMenu": "Otwórz menu", "resource": "Zasoby", "title": "Tytuł", "created": "Utworzono", "expires": "Wygasa", "never": "Nigdy", "shareErrorSelectResource": "Wybierz zasób", "proxyResourceTitle": "Zarządzaj zasobami publicznymi", "proxyResourceDescription": "Twórz i zarządzaj zasobami, które są publicznie dostępne w przeglądarce internetowej", "proxyResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web", "proxyResourcesBannerDescription": "Zasoby publiczne to proxy HTTPS lub TCP/UDP dostępne dla każdego w internecie za pośrednictwem przeglądarki internetowej. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.", "clientResourceTitle": "Zarządzaj zasobami prywatnymi", "clientResourceDescription": "Twórz i zarządzaj zasobami, które są dostępne tylko za pośrednictwem połączonego klienta", "privateResourcesBannerTitle": "Zero zaufania do prywatnego dostępu", "privateResourcesBannerDescription": "Zasoby prywatne korzystają z zabezpieczeń zero-trust, zapewniając dostęp do zasobów użytkownikom i maszynom, którym wyraźnie udzielasz dostępu. Połącz urządzenia użytkowników lub klientów maszyn z tymi zasobami przez bezpieczną prywatną sieć wirtualną.", "resourcesSearch": "Szukaj zasobów...", "resourceAdd": "Dodaj zasób", "resourceErrorDelte": "Błąd podczas usuwania zasobu", "authentication": "Uwierzytelnianie", "protected": "Chronione", "notProtected": "Niechronione", "resourceMessageRemove": "Po usunięciu zasób nie będzie już dostępny. Wszystkie cele związane z zasobem zostaną również usunięte.", "resourceQuestionRemove": "Czy na pewno chcesz usunąć zasób z organizacji?", "resourceHTTP": "Zasób HTTPS", "resourceHTTPDescription": "Proxy zapytań przez HTTPS przy użyciu w pełni kwalifikowanej nazwy domeny.", "resourceRaw": "Surowy zasób TCP/UDP", "resourceRawDescription": "Proxy zapytań przez surowe TCP/UDP przy użyciu numeru portu.", "resourceRawDescriptionCloud": "Proxy żądania przesyłania danych nad surowym TCP/UDP przy użyciu numeru portu. Wymaga UŻYTKOWANIA PALIWA węzła.", "resourceCreate": "Utwórz zasób", "resourceCreateDescription": "Wykonaj poniższe kroki, aby utworzyć nowy zasób", "resourceSeeAll": "Zobacz wszystkie zasoby", "resourceInfo": "Informacje o zasobach", "resourceNameDescription": "To jest wyświetlana nazwa zasobu.", "siteSelect": "Wybierz witrynę", "siteSearch": "Szukaj witryny", "siteNotFound": "Nie znaleziono witryny.", "selectCountry": "Wybierz kraj", "searchCountries": "Szukaj krajów...", "noCountryFound": "Nie znaleziono kraju.", "siteSelectionDescription": "Ta strona zapewni połączenie z celem.", "resourceType": "Typ zasobu", "resourceTypeDescription": "Określ jak uzyskać dostęp do zasobu", "resourceHTTPSSettings": "Ustawienia HTTPS", "resourceHTTPSSettingsDescription": "Skonfiguruj jak zasób będzie dostępny przez HTTPS", "domainType": "Typ domeny", "subdomain": "Poddomena", "baseDomain": "Bazowa domena", "subdomnainDescription": "Poddomena, w której zasób będzie dostępny.", "resourceRawSettings": "Ustawienia TCP/UDP", "resourceRawSettingsDescription": "Skonfiguruj jak zasób będzie dostępny przez TCP/UDP", "protocol": "Protokół", "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", "resourceAddEntrypoints": "Traefik: Dodaj punkty wejścia", "resourceExposePorts": "Gerbil: Podnieś porty w Komponencie Dockera", "resourceLearnRaw": "Dowiedz się, jak skonfigurować zasoby TCP/UDP", "resourceBack": "Powrót do zasobów", "resourceGoTo": "Przejdź do zasobu", "resourceDelete": "Usuń zasób", "resourceDeleteConfirm": "Potwierdź usunięcie zasobu", "visibility": "Widoczność", "enabled": "Włączone", "disabled": "Wyłączone", "general": "Ogólny", "generalSettings": "Ustawienia ogólne", "proxy": "Serwer pośredniczący", "internal": "Wewnętrzny", "rules": "Regulamin", "resourceSettingDescription": "Skonfiguruj ustawienia zasobu", "resourceSetting": "Ustawienia {resourceName}", "alwaysAllow": "Omijanie uwierzytelniania", "alwaysDeny": "Blokuj dostęp", "passToAuth": "Przekaż do Autoryzacji", "orgSettingsDescription": "Skonfiguruj ustawienia organizacji", "orgGeneralSettings": "Ustawienia organizacji", "orgGeneralSettingsDescription": "Zarządzaj szczegółami i konfiguracją organizacji", "saveGeneralSettings": "Zapisz ustawienia ogólne", "saveSettings": "Zapisz ustawienia", "orgDangerZone": "Strefa zagrożenia", "orgDangerZoneDescription": "Po usunięciu tej organizacji nie ma odwrotu. Upewnij się.", "orgDelete": "Usuń organizację", "orgDeleteConfirm": "Potwierdź usunięcie organizacji", "orgMessageRemove": "Ta akcja jest nieodwracalna i usunie wszystkie powiązane dane.", "orgMessageConfirm": "Aby potwierdzić, wpisz nazwę organizacji poniżej.", "orgQuestionRemove": "Czy na pewno chcesz usunąć organizację?", "orgUpdated": "Organizacja zaktualizowana", "orgUpdatedDescription": "Organizacja została zaktualizowana.", "orgErrorUpdate": "Nie udało się zaktualizować organizacji", "orgErrorUpdateMessage": "Wystąpił błąd podczas aktualizacji organizacji.", "orgErrorFetch": "Nie udało się pobrać organizacji", "orgErrorFetchMessage": "Wystąpił błąd podczas wyświetlania Twoich organizacji", "orgErrorDelete": "Nie udało się usunąć organizacji", "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", "accessUsersDescription": "Zaproś użytkowników z dostępem do tej organizacji i zarządzaj nimi", "accessUsersSearch": "Szukaj użytkowników...", "accessUserCreate": "Utwórz użytkownika", "accessUserRemove": "Usuń użytkownika", "username": "Nazwa użytkownika", "identityProvider": "Dostawca tożsamości", "role": "Rola", "nameRequired": "Nazwa jest wymagana", "accessRolesManage": "Zarządzaj rolami", "accessRolesDescription": "Tworzenie ról dla użytkowników w organizacji i zarządzanie nimi", "accessRolesSearch": "Szukaj ról...", "accessRolesAdd": "Dodaj rolę", "accessRoleDelete": "Usuń rolę", "accessApprovalsManage": "Zarządzaj zatwierdzaniem", "accessApprovalsDescription": "Przeglądaj i zarządzaj oczekującymi zatwierdzeniami dostępu do tej organizacji", "description": "Opis", "inviteTitle": "Otwórz zaproszenia", "inviteDescription": "Zarządzaj zaproszeniami dla innych użytkowników do dołączenia do organizacji", "inviteSearch": "Szukaj zaproszeń...", "minutes": "Protokoły", "hours": "Godziny", "days": "Dni", "weeks": "Tygodnie", "months": "Miesiące", "years": "Lata", "day": "{count, plural, one {# dzień} few {# dni} many {# dni} other {# dni}}", "apiKeysTitle": "Informacje o kluczu API", "apiKeysConfirmCopy2": "Musisz potwierdzić, że skopiowałeś klucz API.", "apiKeysErrorCreate": "Błąd podczas tworzenia klucza API", "apiKeysErrorSetPermission": "Błąd podczas ustawiania uprawnień", "apiKeysCreate": "Generuj klucz API", "apiKeysCreateDescription": "Wygeneruj nowy klucz API dla organizacji", "apiKeysGeneralSettings": "Uprawnienia", "apiKeysGeneralSettingsDescription": "Określ, co ten klucz API może zrobić", "apiKeysList": "Nowy klucz API", "apiKeysSave": "Zapisz klucz API", "apiKeysSaveDescription": "Będziesz mógł zobaczyć to tylko raz. Upewnij się, że skopiujesz go w bezpieczne miejsce.", "apiKeysInfo": "Kluczem API jest:", "apiKeysConfirmCopy": "Skopiowałem klucz API", "generate": "Generuj", "done": "Gotowe", "apiKeysSeeAll": "Zobacz wszystkie klucze API", "apiKeysPermissionsErrorLoadingActions": "Błąd podczas ładowania akcji klucza API", "apiKeysPermissionsErrorUpdate": "Błąd podczas ustawiania uprawnień", "apiKeysPermissionsUpdated": "Uprawnienia zaktualizowane", "apiKeysPermissionsUpdatedDescription": "Uprawnienia zostały zaktualizowane.", "apiKeysPermissionsGeneralSettings": "Uprawnienia", "apiKeysPermissionsGeneralSettingsDescription": "Określ, co ten klucz API może zrobić", "apiKeysPermissionsSave": "Zapisz uprawnienia", "apiKeysPermissionsTitle": "Uprawnienia", "apiKeys": "Klucze API", "searchApiKeys": "Szukaj kluczy API...", "apiKeysAdd": "Generuj klucz API", "apiKeysErrorDelete": "Błąd podczas usuwania klucza API", "apiKeysErrorDeleteMessage": "Błąd podczas usuwania klucza API", "apiKeysQuestionRemove": "Czy na pewno chcesz usunąć klucz API z organizacji?", "apiKeysMessageRemove": "Po usunięciu klucz API nie będzie już mógł być używany.", "apiKeysDeleteConfirm": "Potwierdź usunięcie klucza API", "apiKeysDelete": "Usuń klucz API", "apiKeysManage": "Zarządzaj kluczami API", "apiKeysDescription": "Klucze API służą do uwierzytelniania z API integracji", "apiKeysSettings": "Ustawienia {apiKeyName}", "userTitle": "Zarządzaj wszystkimi użytkownikami", "userDescription": "Zobacz i zarządzaj wszystkimi użytkownikami w systemie", "userAbount": "O zarządzaniu użytkownikami", "userAbountDescription": "Ta tabela wyświetla wszystkie obiekty użytkownika root w systemie. Każdy użytkownik może należeć do wielu organizacji. Usunięcie użytkownika z organizacji nie usuwa ich głównego obiektu użytkownika - pozostanie on w systemie. Aby całkowicie usunąć użytkownika z systemu, musisz usunąć jego obiekt root użytkownika za pomocą akcji usuwania z tej tabeli.", "userServer": "Użytkownicy serwera", "userSearch": "Szukaj użytkowników serwera...", "userErrorDelete": "Błąd podczas usuwania użytkownika", "userDeleteConfirm": "Potwierdź usunięcie użytkownika", "userDeleteServer": "Usuń użytkownika z serwera", "userMessageRemove": "Użytkownik zostanie usunięty ze wszystkich organizacji i całkowicie usunięty z serwera.", "userQuestionRemove": "Czy na pewno chcesz trwale usunąć użytkownika z serwera?", "licenseKey": "Klucz licencyjny", "valid": "Prawidłowy", "numberOfSites": "Liczba witryn", "licenseKeySearch": "Szukaj kluczy licencyjnych...", "licenseKeyAdd": "Dodaj klucz licencyjny", "type": "Typ", "licenseKeyRequired": "Klucz licencyjny jest wymagany", "licenseTermsAgree": "Musisz wyrazić zgodę na warunki licencji", "licenseErrorKeyLoad": "Nie udało się załadować kluczy licencyjnych", "licenseErrorKeyLoadDescription": "Wystąpił błąd podczas ładowania kluczy licencyjnych.", "licenseErrorKeyDelete": "Nie udało się usunąć klucza licencyjnego", "licenseErrorKeyDeleteDescription": "Wystąpił błąd podczas usuwania klucza licencyjnego.", "licenseKeyDeleted": "Klucz licencji został usunięty", "licenseKeyDeletedDescription": "Klucz licencyjny został usunięty.", "licenseErrorKeyActivate": "Nie udało się aktywować klucza licencji", "licenseErrorKeyActivateDescription": "Wystąpił błąd podczas aktywacji klucza licencyjnego.", "licenseAbout": "O licencjonowaniu", "communityEdition": "Edycja Społecznościowa", "licenseAboutDescription": "Dotyczy to przedsiębiorstw i przedsiębiorstw, którzy stosują Pangolin w środowisku handlowym. Jeśli używasz Pangolin do użytku osobistego, możesz zignorować tę sekcję.", "licenseKeyActivated": "Klucz licencyjny aktywowany", "licenseKeyActivatedDescription": "Klucz licencyjny został pomyślnie aktywowany.", "licenseErrorKeyRecheck": "Nie udało się ponownie sprawdzić kluczy licencyjnych", "licenseErrorKeyRecheckDescription": "Wystąpił błąd podczas ponownego sprawdzania kluczy licencyjnych.", "licenseErrorKeyRechecked": "Klucze licencyjne ponownie sprawdzone", "licenseErrorKeyRecheckedDescription": "Wszystkie klucze licencyjne zostały ponownie sprawdzone", "licenseActivateKey": "Aktywuj klucz licencyjny", "licenseActivateKeyDescription": "Wprowadź klucz licencyjny, aby go aktywować.", "licenseActivate": "Aktywuj licencję", "licenseAgreement": "Zaznaczając to pole, potwierdzasz, że przeczytałeś i zgadzasz się na warunki licencji odpowiadające poziomowi powiązanemu z kluczem licencyjnym.", "fossorialLicense": "Zobacz Fossorial Commercial License & Subskrypcja", "licenseMessageRemove": "Spowoduje to usunięcie klucza licencyjnego i wszystkich przypisanych przez niego uprawnień.", "licenseMessageConfirm": "Aby potwierdzić, wpisz klucz licencyjny poniżej.", "licenseQuestionRemove": "Czy na pewno chcesz usunąć klucz licencyjny?", "licenseKeyDelete": "Usuń klucz licencyjny", "licenseKeyDeleteConfirm": "Potwierdź usunięcie klucza licencyjnego", "licenseTitle": "Zarządzaj statusem licencji", "licenseTitleDescription": "Wyświetl i zarządzaj kluczami licencyjnymi w systemie", "licenseHost": "Licencja hosta", "licenseHostDescription": "Zarządzaj głównym kluczem licencyjnym hosta.", "licensedNot": "Brak licencji", "hostId": "ID hosta", "licenseReckeckAll": "Sprawdź ponownie wszystkie klucze", "licenseSiteUsage": "Użycie witryn", "licenseSiteUsageDecsription": "Zobacz liczbę witryn korzystających z tej licencji.", "licenseNoSiteLimit": "Nie ma limitu liczby witryn używających nielicencjonowanego hosta.", "licensePurchase": "Kup licencję", "licensePurchaseSites": "Kup dodatkowe witryny", "licenseSitesUsedMax": "Użyte strony {usedSites} z {maxSites}", "licenseSitesUsed": "{count, plural, =0 {# witryn} one {# witryna} few {# witryny} many {# witryn} other {# witryn}} w systemie.", "licensePurchaseDescription": "Wybierz ile witryn chcesz {selectedMode, select, license {kupić licencję. Zawsze możesz dodać więcej witryn później.} other {dodaj do swojej istniejącej licencji.}}", "licenseFee": "Opłata licencyjna", "licensePriceSite": "Cena za witrynę", "total": "Łącznie", "licenseContinuePayment": "Przejdź do płatności", "pricingPage": "strona cenowa", "pricingPortal": "Zobacz portal zakupu", "licensePricingPage": "Aby uzyskać najnowsze ceny i rabaty, odwiedź ", "invite": "Zaproszenia", "inviteRegenerate": "Wygeneruj ponownie zaproszenie", "inviteRegenerateDescription": "Unieważnij poprzednie zaproszenie i utwórz nowe", "inviteRemove": "Usuń zaproszenie", "inviteRemoveError": "Nie udało się usunąć zaproszenia", "inviteRemoveErrorDescription": "Wystąpił błąd podczas usuwania zaproszenia.", "inviteRemoved": "Zaproszenie usunięte", "inviteRemovedDescription": "Zaproszenie dla {email} zostało usunięte.", "inviteQuestionRemove": "Czy na pewno chcesz usunąć zaproszenie?", "inviteMessageRemove": "Po usunięciu to zaproszenie nie będzie już ważne. Zawsze możesz ponownie zaprosić użytkownika później.", "inviteMessageConfirm": "Aby potwierdzić, wpisz poniżej adres email zaproszenia.", "inviteQuestionRegenerate": "Czy na pewno chcesz ponownie wygenerować zaproszenie {email}? Spowoduje to unieważnienie poprzedniego zaproszenia.", "inviteRemoveConfirm": "Potwierdź usunięcie zaproszenia", "inviteRegenerated": "Zaproszenie wygenerowane ponownie", "inviteSent": "Nowe zaproszenie zostało wysłane do {email}.", "inviteSentEmail": "Wyślij powiadomienie email do użytkownika", "inviteGenerate": "Nowe zaproszenie zostało wygenerowane dla {email}.", "inviteDuplicateError": "Zduplikowane zaproszenie", "inviteDuplicateErrorDescription": "Zaproszenie dla tego użytkownika już istnieje.", "inviteRateLimitError": "Przekroczono limit żądań", "inviteRateLimitErrorDescription": "Przekroczyłeś limit 3 regeneracji na godzinę. Spróbuj ponownie później.", "inviteRegenerateError": "Nie udało się ponownie wygenerować zaproszenia", "inviteRegenerateErrorDescription": "Wystąpił błąd podczas ponownego generowania zaproszenia.", "inviteValidityPeriod": "Okres ważności", "inviteValidityPeriodSelect": "Wybierz okres ważności", "inviteRegenerateMessage": "Zaproszenie zostało ponownie wygenerowane. Użytkownik musi uzyskać dostęp do poniższego linku, aby zaakceptować zaproszenie.", "inviteRegenerateButton": "Wygeneruj ponownie", "expiresAt": "Wygasa w dniu", "accessRoleUnknown": "Nieznana rola", "placeholder": "Symbol zastępczy", "userErrorOrgRemove": "Nie udało się usunąć użytkownika", "userErrorOrgRemoveDescription": "Wystąpił błąd podczas usuwania użytkownika.", "userOrgRemoved": "Użytkownik usunięty", "userOrgRemovedDescription": "Użytkownik {email} został usunięty z organizacji.", "userQuestionOrgRemove": "Czy na pewno chcesz usunąć tego użytkownika z organizacji?", "userMessageOrgRemove": "Po usunięciu ten użytkownik nie będzie miał już dostępu do organizacji. Zawsze możesz ponownie go zaprosić później, ale będzie musiał ponownie zaakceptować zaproszenie.", "userRemoveOrgConfirm": "Potwierdź usunięcie użytkownika", "userRemoveOrg": "Usuń użytkownika z organizacji", "users": "Użytkownicy", "accessRoleMember": "Członek", "accessRoleOwner": "Właściciel", "userConfirmed": "Potwierdzony", "idpNameInternal": "Wewnętrzny", "emailInvalid": "Nieprawidłowy adres e-mail", "inviteValidityDuration": "Proszę wybrać okres ważności", "accessRoleSelectPlease": "Proszę wybrać rolę", "usernameRequired": "Nazwa użytkownika jest wymagana", "idpSelectPlease": "Proszę wybrać dostawcę tożsamości", "idpGenericOidc": "Ogólny dostawca OAuth2/OIDC.", "accessRoleErrorFetch": "Nie udało się pobrać ról", "accessRoleErrorFetchDescription": "Wystąpił błąd podczas pobierania ról", "idpErrorFetch": "Nie udało się pobrać dostawców tożsamości", "idpErrorFetchDescription": "Wystąpił błąd podczas pobierania dostawców tożsamości", "userErrorExists": "Użytkownik już istnieje", "userErrorExistsDescription": "Ten użytkownik jest już członkiem organizacji.", "inviteError": "Nie udało się zaprosić użytkownika", "inviteErrorDescription": "Wystąpił błąd podczas zapraszania użytkownika", "userInvited": "Użytkownik zaproszony", "userInvitedDescription": "Użytkownik został pomyślnie zaproszony.", "userErrorCreate": "Nie udało się utworzyć użytkownika", "userErrorCreateDescription": "Wystąpił błąd podczas tworzenia użytkownika", "userCreated": "Utworzono użytkownika", "userCreatedDescription": "Użytkownik został pomyślnie utworzony.", "userTypeInternal": "Użytkownik wewnętrzny", "userTypeInternalDescription": "Zaproś użytkownika do dołączenia do organizacji bezpośrednio.", "userTypeExternal": "Użytkownik zewnętrzny", "userTypeExternalDescription": "Utwórz użytkownika z zewnętrznym dostawcą tożsamości.", "accessUserCreateDescription": "Wykonaj poniższe kroki, aby utworzyć nowego użytkownika", "userSeeAll": "Zobacz wszystkich użytkowników", "userTypeTitle": "Typ użytkownika", "userTypeDescription": "Określ, jak chcesz utworzyć użytkownika", "userSettings": "Informacje o użytkowniku", "userSettingsDescription": "Wprowadź dane nowego użytkownika", "inviteEmailSent": "Wyślij email z zaproszeniem do użytkownika", "inviteValid": "Ważne przez", "selectDuration": "Wybierz okres", "selectResource": "Wybierz zasób", "filterByResource": "Filtruj według zasobów", "selectApprovalState": "Wybierz województwo zatwierdzające", "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", "deniedApproval": "Odrzucono zatwierdzenie", "all": "Wszystko", "deny": "Odmowa", "viewDetails": "Zobacz szczegóły", "requestingNewDeviceApproval": "zażądano nowego urządzenia", "resetFilters": "Resetuj filtry", "totalBlocked": "Żądania zablokowane przez Pangolina", "totalRequests": "Wszystkich Żądań", "requestsByCountry": "Żądania według kraju", "requestsByDay": "Żądania wg dnia", "blocked": "Zablokowane", "allowed": "Dozwolone", "topCountries": "Najlepsze kraje", "accessRoleSelect": "Wybierz rolę", "inviteEmailSentDescription": "Email został wysłany do użytkownika z linkiem dostępu poniżej. Musi on uzyskać dostęp do linku, aby zaakceptować zaproszenie.", "inviteSentDescription": "Użytkownik został zaproszony. Musi uzyskać dostęp do poniższego linku, aby zaakceptować zaproszenie.", "inviteExpiresIn": "Zaproszenie wygaśnie za {days, plural, one {# dzień} few {# dni} many {# dni} other {# dni}}.", "idpTitle": "Informacje ogólne", "idpSelect": "Wybierz dostawcę tożsamości dla użytkownika zewnętrznego", "idpNotConfigured": "Nie skonfigurowano żadnych dostawców tożsamości. Skonfiguruj dostawcę tożsamości przed utworzeniem użytkowników zewnętrznych.", "usernameUniq": "Musi to odpowiadać unikalnej nazwie użytkownika istniejącej u wybranego dostawcy tożsamości.", "emailOptional": "E-mail (Opcjonalnie)", "nameOptional": "Nazwa (Opcjonalnie)", "accessControls": "Kontrola dostępu", "userDescription2": "Zarządzaj ustawieniami tego użytkownika", "accessRoleErrorAdd": "Nie udało się dodać użytkownika do roli", "accessRoleErrorAddDescription": "Wystąpił błąd podczas dodawania użytkownika do roli.", "userSaved": "Użytkownik zapisany", "userSavedDescription": "Użytkownik został zaktualizowany.", "autoProvisioned": "Przesłane automatycznie", "autoProvisionedDescription": "Pozwól temu użytkownikowi na automatyczne zarządzanie przez dostawcę tożsamości", "accessControlsDescription": "Zarządzaj tym, do czego użytkownik ma dostęp i co może robić w organizacji", "accessControlsSubmit": "Zapisz kontrole dostępu", "roles": "Role", "accessUsersRoles": "Zarządzaj użytkownikami i rolami", "accessUsersRolesDescription": "Zaproś użytkowników i dodaj je do ról do zarządzania dostępem do organizacji", "key": "Klucz", "createdAt": "Utworzono", "proxyErrorInvalidHeader": "Nieprawidłowa wartość niestandardowego nagłówka hosta. Użyj formatu nazwy domeny lub zapisz pusty, aby usunąć niestandardowy nagłówek hosta.", "proxyErrorTls": "Nieprawidłowa nazwa serwera TLS. Użyj formatu nazwy domeny lub zapisz pusty, aby usunąć nazwę serwera TLS.", "proxyEnableSSL": "Włącz SSL", "proxyEnableSSLDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z celami.", "target": "Target", "configureTarget": "Konfiguruj Targety", "targetErrorFetch": "Nie udało się pobrać celów", "targetErrorFetchDescription": "Wystąpił błąd podczas pobierania celów", "siteErrorFetch": "Nie udało się pobrać zasobu", "siteErrorFetchDescription": "Wystąpił błąd podczas pobierania zasobu", "targetErrorDuplicate": "Duplikat celu", "targetErrorDuplicateDescription": "Cel o tych ustawieniach już istnieje", "targetWireGuardErrorInvalidIp": "Nieprawidłowy adres IP celu", "targetWireGuardErrorInvalidIpDescription": "Adres IP celu musi znajdować się w podsieci witryny", "targetsUpdated": "Cele zaktualizowane", "targetsUpdatedDescription": "Cele i ustawienia zostały pomyślnie zaktualizowane", "targetsErrorUpdate": "Nie udało się zaktualizować celów", "targetsErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji celów", "targetTlsUpdate": "Ustawienia TLS zaktualizowane", "targetTlsUpdateDescription": "Ustawienia TLS zostały pomyślnie zaktualizowane", "targetErrorTlsUpdate": "Nie udało się zaktualizować ustawień TLS", "targetErrorTlsUpdateDescription": "Wystąpił błąd podczas aktualizacji ustawień TLS", "proxyUpdated": "Ustawienia proxy zaktualizowane", "proxyUpdatedDescription": "Ustawienia proxy zostały pomyślnie zaktualizowane", "proxyErrorUpdate": "Nie udało się zaktualizować ustawień proxy", "proxyErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji ustawień proxy", "targetAddr": "Host", "targetPort": "Port", "targetProtocol": "Protokół", "targetTlsSettings": "Konfiguracja bezpiecznego połączenia", "targetTlsSettingsDescription": "Skonfiguruj ustawienia SSL/TLS dla zasobu", "targetTlsSettingsAdvanced": "Zaawansowane ustawienia TLS", "targetTlsSni": "Nazwa serwera TLS", "targetTlsSniDescription": "Nazwa serwera TLS do użycia dla SNI. Pozostaw puste, aby użyć domyślnej.", "targetTlsSubmit": "Zapisz ustawienia", "targets": "Konfiguracja celów", "targetsDescription": "Ustaw cele dla ruchu na trasie w celu obsługi zaplecza", "targetStickySessions": "Włącz sesje trwałe", "targetStickySessionsDescription": "Utrzymuj połączenia na tym samym celu backendowym przez całą sesję.", "methodSelect": "Wybierz metodę", "targetSubmit": "Dodaj cel", "targetNoOne": "Ten zasób nie ma żadnych celów. Dodaj cel do skonfigurowania adresów wysyłania żądań do backendu.", "targetNoOneDescription": "Dodanie więcej niż jednego celu powyżej włączy równoważenie obciążenia.", "targetsSubmit": "Zapisz cele", "addTarget": "Dodaj cel", "targetErrorInvalidIp": "Nieprawidłowy adres IP", "targetErrorInvalidIpDescription": "Wprowadź prawidłowy adres IP lub nazwę hosta", "targetErrorInvalidPort": "Nieprawidłowy port", "targetErrorInvalidPortDescription": "Wprowadź prawidłowy numer portu", "targetErrorNoSite": "Nie wybrano witryny", "targetErrorNoSiteDescription": "Wybierz witrynę docelową", "targetCreated": "Cel utworzony", "targetCreatedDescription": "Cel został utworzony pomyślnie", "targetErrorCreate": "Nie udało się utworzyć celu", "targetErrorCreateDescription": "Wystąpił błąd podczas tworzenia celu", "tlsServerName": "Nazwa serwera TLS", "tlsServerNameDescription": "Nazwa serwera TLS do użycia dla SNI", "save": "Zapisz", "proxyAdditional": "Dodatkowe ustawienia proxy", "proxyAdditionalDescription": "Skonfiguruj sposób obsługi ustawień proxy", "proxyCustomHeader": "Niestandardowy nagłówek hosta", "proxyCustomHeaderDescription": "Nagłówek hosta do ustawienia podczas proxy żądań. Pozostaw puste, aby użyć domyślnego.", "proxyAdditionalSubmit": "Zapisz ustawienia proxy", "subnetMaskErrorInvalid": "Nieprawidłowa maska podsieci. Musi być między 0 a 32.", "ipAddressErrorInvalidFormat": "Nieprawidłowy format adresu IP", "ipAddressErrorInvalidOctet": "Nieprawidłowy oktet adresu IP", "path": "Ścieżka", "matchPath": "Ścieżka dopasowania", "ipAddressRange": "Zakres IP", "rulesErrorFetch": "Nie udało się pobrać reguł", "rulesErrorFetchDescription": "Wystąpił błąd podczas pobierania reguł", "rulesErrorDuplicate": "Duplikat reguły", "rulesErrorDuplicateDescription": "Reguła o tych ustawieniach już istnieje", "rulesErrorInvalidIpAddressRange": "Nieprawidłowy CIDR", "rulesErrorInvalidIpAddressRangeDescription": "Wprowadź prawidłową wartość CIDR", "rulesErrorInvalidUrl": "Nieprawidłowa ścieżka URL", "rulesErrorInvalidUrlDescription": "Wprowadź prawidłową wartość ścieżki URL", "rulesErrorInvalidIpAddress": "Nieprawidłowe IP", "rulesErrorInvalidIpAddressDescription": "Wprowadź prawidłowy adres IP", "rulesErrorUpdate": "Nie udało się zaktualizować reguł", "rulesErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji reguł", "rulesUpdated": "Włącz reguły", "rulesUpdatedDescription": "Ocena reguł została zaktualizowana", "rulesMatchIpAddressRangeDescription": "Wprowadź adres w formacie CIDR (np. 103.21.244.0/22)", "rulesMatchIpAddress": "Wprowadź adres IP (np. 103.21.244.12)", "rulesMatchUrl": "Wprowadź ścieżkę URL lub wzorzec (np. /api/v1/todos lub /api/v1/*)", "rulesErrorInvalidPriority": "Nieprawidłowy priorytet", "rulesErrorInvalidPriorityDescription": "Wprowadź prawidłowy priorytet", "rulesErrorDuplicatePriority": "Zduplikowane priorytety", "rulesErrorDuplicatePriorityDescription": "Wprowadź unikalne priorytety", "ruleUpdated": "Reguły zaktualizowane", "ruleUpdatedDescription": "Reguły zostały pomyślnie zaktualizowane", "ruleErrorUpdate": "Operacja nie powiodła się", "ruleErrorUpdateDescription": "Wystąpił błąd podczas operacji zapisu", "rulesPriority": "Priorytet", "rulesAction": "Akcja", "rulesMatchType": "Typ dopasowania", "value": "Wartość", "rulesAbout": "O regułach", "rulesAboutDescription": "Reguły pozwalają kontrolować dostęp do zasobu na podstawie zestawu kryteriów. Możesz utworzyć reguły, aby zezwolić lub odmówić dostępu w oparciu o adres IP lub ścieżkę URL.", "rulesActions": "Akcje", "rulesActionAlwaysAllow": "Zawsze zezwalaj: Pomiń wszystkie metody uwierzytelniania", "rulesActionAlwaysDeny": "Zawsze odmawiaj: Blokuj wszystkie żądania; nie można próbować uwierzytelniania", "rulesActionPassToAuth": "Przekaż do Autoryzacji: Zezwól na próby metod uwierzytelniania", "rulesMatchCriteria": "Kryteria dopasowania", "rulesMatchCriteriaIpAddress": "Dopasuj konkretny adres IP", "rulesMatchCriteriaIpAddressRange": "Dopasuj zakres adresów IP w notacji CIDR", "rulesMatchCriteriaUrl": "Dopasuj ścieżkę URL lub wzorzec", "rulesEnable": "Włącz reguły", "rulesEnableDescription": "Włącz lub wyłącz ocenę reguł dla tego zasobu", "rulesResource": "Konfiguracja reguł zasobu", "rulesResourceDescription": "Skonfiguruj reguły, aby kontrolować dostęp do zasobu", "ruleSubmit": "Dodaj regułę", "rulesNoOne": "Brak reguł. Dodaj regułę używając formularza.", "rulesOrder": "Reguły są oceniane według priorytetu w kolejności rosnącej.", "rulesSubmit": "Zapisz reguły", "resourceErrorCreate": "Błąd podczas tworzenia zasobu", "resourceErrorCreateDescription": "Wystąpił błąd podczas tworzenia zasobu", "resourceErrorCreateMessage": "Błąd podczas tworzenia zasobu:", "resourceErrorCreateMessageDescription": "Wystąpił nieoczekiwany błąd", "sitesErrorFetch": "Błąd podczas pobierania witryn", "sitesErrorFetchDescription": "Wystąpił błąd podczas pobierania witryn", "domainsErrorFetch": "Błąd podczas pobierania domen", "domainsErrorFetchDescription": "Wystąpił błąd podczas pobierania domen", "none": "Brak", "unknown": "Nieznany", "resources": "Zasoby", "resourcesDescription": "Zasoby to proxy do aplikacji działających w sieci prywatnej. Utwórz zasób dla dowolnej usługi HTTP/HTTPS lub surowej usługi TCP/UDP w sieci prywatnej. Każdy zasób musi być podłączony do witryny, aby umożliwić prywatną, bezpieczną łączność przez zaszyfrowany tunel WireGuard.", "resourcesWireGuardConnect": "Bezpieczne połączenie z szyfrowaniem WireGuard", "resourcesMultipleAuthenticationMethods": "Skonfiguruj wiele metod uwierzytelniania", "resourcesUsersRolesAccess": "Kontrola dostępu oparta na użytkownikach i rolach", "resourcesErrorUpdate": "Nie udało się przełączyć zasobu", "resourcesErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji zasobu", "access": "Dostęp", "accessControl": "Kontrola dostępu", "shareLink": "Link udostępniania {resource}", "resourceSelect": "Wybierz zasób", "shareLinks": "Linki udostępniania", "share": "Linki do udostępniania", "shareDescription2": "Utwórz linki do zasobów, które można współdzielić. Linki zapewniają tymczasowy lub nieograniczony dostęp do twojego zasobu. Możesz skonfigurować czas ważności linku, gdy go utworzysz.", "shareEasyCreate": "Łatwe tworzenie i udostępnianie", "shareConfigurableExpirationDuration": "Konfigurowalny okres ważności", "shareSecureAndRevocable": "Bezpieczne i odwoływalne", "nameMin": "Nazwa musi mieć co najmniej {len} znaków.", "nameMax": "Nazwa nie może być dłuższa niż {len} znaków.", "sitesConfirmCopy": "Potwierdź, że skopiowałeś konfigurację.", "unknownCommand": "Nieznane polecenie", "newtErrorFetchReleases": "Nie udało się pobrać informacji o wydaniu: {err}", "newtErrorFetchLatest": "Błąd podczas pobierania najnowszego wydania: {err}", "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Sekret", "architecture": "Architektura", "sites": "Witryny", "siteWgAnyClients": "Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał przekierować wewnętrzne zasoby za pomocą adresu IP.", "siteWgCompatibleAllClients": "Kompatybilny ze wszystkimi klientami WireGuard", "siteWgManualConfigurationRequired": "Wymagana konfiguracja ręczna", "userErrorNotAdminOrOwner": "Użytkownik nie jest administratorem ani właścicielem", "pangolinSettings": "Ustawienia - Pangolin", "accessRoleYour": "Twoja rola:", "accessRoleSelect2": "Wybierz role", "accessUserSelect": "Wybierz użytkowników", "otpEmailEnter": "Wprowadź adres e-mail", "otpEmailEnterDescription": "Naciśnij enter, aby dodać adres e-mail po wpisaniu go w polu.", "otpEmailErrorInvalid": "Nieprawidłowy adres e-mail. Znak wieloznaczny (*) musi być całą częścią lokalną.", "otpEmailSmtpRequired": "Wymagany SMTP", "otpEmailSmtpRequiredDescription": "SMTP musi być włączony na serwerze, aby korzystać z uwierzytelniania jednorazowym hasłem.", "otpEmailTitle": "Hasła jednorazowe", "otpEmailTitleDescription": "Wymagaj uwierzytelniania opartego na e-mail dla dostępu do zasobu", "otpEmailWhitelist": "Biała lista e-mail", "otpEmailWhitelistList": "Dozwolone adresy e-mail", "otpEmailWhitelistListDescription": "Tylko użytkownicy z tymi adresami e-mail będą mieli dostęp do tego zasobu. Otrzymają prośbę o wprowadzenie jednorazowego hasła wysłanego na ich e-mail. Można użyć znaków wieloznacznych (*@example.com), aby zezwolić na dowolny adres e-mail z domeny.", "otpEmailWhitelistSave": "Zapisz białą listę", "passwordAdd": "Dodaj hasło", "passwordRemove": "Usuń hasło", "pincodeAdd": "Dodaj kod PIN", "pincodeRemove": "Usuń kod PIN", "resourceAuthMethods": "Metody uwierzytelniania", "resourceAuthMethodsDescriptions": "Zezwól na dostęp do zasobu przez dodatkowe metody uwierzytelniania", "resourceAuthSettingsSave": "Zapisano pomyślnie", "resourceAuthSettingsSaveDescription": "Ustawienia uwierzytelniania zostały zapisane", "resourceErrorAuthFetch": "Nie udało się pobrać danych", "resourceErrorAuthFetchDescription": "Wystąpił błąd podczas pobierania danych", "resourceErrorPasswordRemove": "Błąd podczas usuwania hasła zasobu", "resourceErrorPasswordRemoveDescription": "Wystąpił błąd podczas usuwania hasła zasobu", "resourceErrorPasswordSetup": "Błąd podczas ustawiania hasła zasobu", "resourceErrorPasswordSetupDescription": "Wystąpił błąd podczas ustawiania hasła zasobu", "resourceErrorPincodeRemove": "Błąd podczas usuwania kodu PIN zasobu", "resourceErrorPincodeRemoveDescription": "Wystąpił błąd podczas usuwania kodu PIN zasobu", "resourceErrorPincodeSetup": "Błąd podczas ustawiania kodu PIN zasobu", "resourceErrorPincodeSetupDescription": "Wystąpił błąd podczas ustawiania kodu PIN zasobu", "resourceErrorUsersRolesSave": "Nie udało się ustawić ról", "resourceErrorUsersRolesSaveDescription": "Wystąpił błąd podczas ustawiania ról", "resourceErrorWhitelistSave": "Nie udało się zapisać białej listy", "resourceErrorWhitelistSaveDescription": "Wystąpił błąd podczas zapisywania białej listy", "resourcePasswordSubmit": "Włącz ochronę hasłem", "resourcePasswordProtection": "Ochrona hasłem {status}", "resourcePasswordRemove": "Hasło zasobu zostało usunięte", "resourcePasswordRemoveDescription": "Hasło zasobu zostało pomyślnie usunięte", "resourcePasswordSetup": "Ustawiono hasło zasobu", "resourcePasswordSetupDescription": "Hasło zasobu zostało pomyślnie ustawione", "resourcePasswordSetupTitle": "Ustaw hasło", "resourcePasswordSetupTitleDescription": "Ustaw hasło, aby chronić ten zasób", "resourcePincode": "Kod PIN", "resourcePincodeSubmit": "Włącz ochronę kodem PIN", "resourcePincodeProtection": "Ochrona kodem PIN {status}", "resourcePincodeRemove": "Usunięto kod PIN zasobu", "resourcePincodeRemoveDescription": "Kod PIN zasobu został pomyślnie usunięty", "resourcePincodeSetup": "Ustawiono kod PIN zasobu", "resourcePincodeSetupDescription": "Kod PIN zasobu został pomyślnie ustawiony", "resourcePincodeSetupTitle": "Ustaw kod PIN", "resourcePincodeSetupTitleDescription": "Ustaw kod PIN, aby chronić ten zasób", "resourceRoleDescription": "Administratorzy zawsze mają dostęp do tego zasobu.", "resourceUsersRoles": "Kontrola dostępu", "resourceUsersRolesDescription": "Skonfiguruj, którzy użytkownicy i role mogą odwiedzać ten zasób", "resourceUsersRolesSubmit": "Zapisz kontrole dostępu", "resourceWhitelistSave": "Zapisano pomyślnie", "resourceWhitelistSaveDescription": "Ustawienia białej listy zostały zapisane", "ssoUse": "Użyj platformy SSO", "ssoUseDescription": "Istniejący użytkownicy będą musieli zalogować się tylko raz dla wszystkich zasobów, które mają to włączone.", "proxyErrorInvalidPort": "Nieprawidłowy numer portu", "subdomainErrorInvalid": "Nieprawidłowa poddomena", "domainErrorFetch": "Błąd podczas pobierania domen", "domainErrorFetchDescription": "Wystąpił błąd podczas pobierania domen", "resourceErrorUpdate": "Nie udało się zaktualizować zasobu", "resourceErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji zasobu", "resourceUpdated": "Zasób zaktualizowany", "resourceUpdatedDescription": "Zasób został pomyślnie zaktualizowany", "resourceErrorTransfer": "Nie udało się przenieść zasobu", "resourceErrorTransferDescription": "Wystąpił błąd podczas przenoszenia zasobu", "resourceTransferred": "Zasób przeniesiony", "resourceTransferredDescription": "Zasób został pomyślnie przeniesiony", "resourceErrorToggle": "Nie udało się przełączyć zasobu", "resourceErrorToggleDescription": "Wystąpił błąd podczas aktualizacji zasobu", "resourceVisibilityTitle": "Widoczność", "resourceVisibilityTitleDescription": "Całkowicie włącz lub wyłącz widoczność zasobu", "resourceGeneral": "Ustawienia ogólne", "resourceGeneralDescription": "Skonfiguruj ustawienia ogólne dla tego zasobu", "resourceEnable": "Włącz zasób", "resourceTransfer": "Przenieś zasób", "resourceTransferDescription": "Przenieś ten zasób do innej witryny", "resourceTransferSubmit": "Przenieś zasób", "siteDestination": "Witryna docelowa", "searchSites": "Szukaj witryn", "countries": "Kraje", "accessRoleCreate": "Utwórz rolę", "accessRoleCreateDescription": "Utwórz nową rolę aby zgrupować użytkowników i zarządzać ich uprawnieniami.", "accessRoleEdit": "Edytuj rolę", "accessRoleEditDescription": "Edytuj informacje o rolach.", "accessRoleCreateSubmit": "Utwórz rolę", "accessRoleCreated": "Rola utworzona", "accessRoleCreatedDescription": "Rola została pomyślnie utworzona.", "accessRoleErrorCreate": "Nie udało się utworzyć roli", "accessRoleErrorCreateDescription": "Wystąpił błąd podczas tworzenia roli.", "accessRoleUpdateSubmit": "Aktualizuj rolę", "accessRoleUpdated": "Rola zaktualizowana", "accessRoleUpdatedDescription": "Rola została pomyślnie zaktualizowana.", "accessApprovalUpdated": "Zatwierdzenie przetworzone", "accessApprovalApprovedDescription": "Ustaw decyzję o zatwierdzeniu wniosku o zatwierdzenie.", "accessApprovalDeniedDescription": "Ustaw decyzję o odrzuceniu wniosku o zatwierdzenie.", "accessRoleErrorUpdate": "Nie udało się zaktualizować roli", "accessRoleErrorUpdateDescription": "Wystąpił błąd podczas aktualizowania roli.", "accessApprovalErrorUpdate": "Nie udało się przetworzyć zatwierdzenia", "accessApprovalErrorUpdateDescription": "Wystąpił błąd podczas przetwarzania zatwierdzenia.", "accessRoleErrorNewRequired": "Nowa rola jest wymagana", "accessRoleErrorRemove": "Nie udało się usunąć roli", "accessRoleErrorRemoveDescription": "Wystąpił błąd podczas usuwania roli.", "accessRoleName": "Nazwa roli", "accessRoleQuestionRemove": "Zamierzasz usunąć rolę `{name}`. Nie możesz cofnąć tej czynności.", "accessRoleRemove": "Usuń rolę", "accessRoleRemoveDescription": "Usuń rolę z organizacji", "accessRoleRemoveSubmit": "Usuń rolę", "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", "licenseTierProfessional": "Licencja Professional", "licenseTierEnterprise": "Licencja Enterprise", "licenseTierPersonal": "Licencja osobista", "licensed": "Licencjonowany", "yes": "Tak", "no": "Nie", "sitesAdditional": "Dodatkowe witryny", "licenseKeys": "Klucze licencyjne", "sitestCountDecrease": "Zmniejsz liczbę witryn", "sitestCountIncrease": "Zwiększ liczbę witryn", "idpManage": "Zarządzaj dostawcami tożsamości", "idpManageDescription": "Wyświetl i zarządzaj dostawcami tożsamości w systemie", "idpGlobalModeBanner": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używa globalnych IdP (współdzielonych ze wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym . Aby włączyć IdP na organizację, edytuj konfigurację serwera i ustaw tryb IdP na org. Zobacz dokumentację. Jeśli chcesz nadal używać globalnych IdP i sprawić, że zniknie to z ustawień organizacji, wyraźnie ustaw tryb globalny w konfiguracji.", "idpGlobalModeBannerUpgradeRequired": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używają globalnych IdP (współdzielonych między wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym . Aby korzystać z dostawców tożsamości na organizację, musisz zaktualizować do edycji Enterprise.", "idpGlobalModeBannerLicenseRequired": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używają globalnych IdP (współdzielonych między wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym . Aby korzystać z dostawców tożsamości na organizację, wymagana jest licencja Enterprise.", "idpDeletedDescription": "Dostawca tożsamości został pomyślnie usunięty", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Czy na pewno chcesz trwale usunąć dostawcę tożsamości?", "idpMessageRemove": "Spowoduje to usunięcie dostawcy tożsamości i wszystkich powiązanych konfiguracji. Użytkownicy uwierzytelniający się przez tego dostawcę nie będą mogli się już zalogować.", "idpMessageConfirm": "Aby potwierdzić, wpisz nazwę dostawcy tożsamości poniżej.", "idpConfirmDelete": "Potwierdź usunięcie dostawcy tożsamości", "idpDelete": "Usuń dostawcę tożsamości", "idp": "Dostawcy tożsamości", "idpSearch": "Szukaj dostawców tożsamości...", "idpAdd": "Dodaj dostawcę tożsamości", "idpClientIdRequired": "Identyfikator klienta jest wymagany.", "idpClientSecretRequired": "Sekret klienta jest wymagany.", "idpErrorAuthUrlInvalid": "URL autoryzacji musi być prawidłowym adresem URL.", "idpErrorTokenUrlInvalid": "URL tokena musi być prawidłowym adresem URL.", "idpPathRequired": "Ścieżka identyfikatora jest wymagana.", "idpScopeRequired": "Zakresy są wymagane.", "idpOidcDescription": "Skonfiguruj dostawcę tożsamości OpenID Connect", "idpCreatedDescription": "Dostawca tożsamości został pomyślnie utworzony", "idpCreate": "Utwórz dostawcę tożsamości", "idpCreateDescription": "Skonfiguruj nowego dostawcę tożsamości do uwierzytelniania użytkowników", "idpSeeAll": "Zobacz wszystkich dostawców tożsamości", "idpSettingsDescription": "Skonfiguruj podstawowe informacje dla swojego dostawcy tożsamości", "idpDisplayName": "Nazwa wyświetlana dla tego dostawcy tożsamości", "idpAutoProvisionUsers": "Automatyczne tworzenie użytkowników", "idpAutoProvisionUsersDescription": "Gdy włączone, użytkownicy będą automatycznie tworzeni w systemie przy pierwszym logowaniu z możliwością mapowania użytkowników do ról i organizacji.", "licenseBadge": "EE", "idpType": "Typ dostawcy", "idpTypeDescription": "Wybierz typ dostawcy tożsamości, który chcesz skonfigurować", "idpOidcConfigure": "Konfiguracja OAuth2/OIDC", "idpOidcConfigureDescription": "Skonfiguruj punkty końcowe i poświadczenia dostawcy OAuth2/OIDC", "idpClientId": "ID klienta", "idpClientIdDescription": "Identyfikator klienta OAuth2 od dostawcy tożsamości", "idpClientSecret": "Sekret klienta", "idpClientSecretDescription": "Sekret klienta OAuth2 od dostawcy tożsamości", "idpAuthUrl": "URL autoryzacji", "idpAuthUrlDescription": "URL punktu końcowego autoryzacji OAuth2", "idpTokenUrl": "URL tokena", "idpTokenUrlDescription": "URL punktu końcowego tokena OAuth2", "idpOidcConfigureAlert": "Ważna informacja", "idpOidcConfigureAlertDescription": "Po utworzeniu dostawcy tożsamości musisz skonfigurować adres URL wywołania zwrotnego w ustawieniach dostawcy tożsamości. Adres zwrotny zostanie podany po pomyślnym utworzeniu.", "idpToken": "Konfiguracja tokena", "idpTokenDescription": "Skonfiguruj jak wydobywać informacje o użytkowniku z tokena ID", "idpJmespathAbout": "O JMESPath", "idpJmespathAboutDescription": "Poniższe ścieżki używają składni JMESPath do wydobywania wartości z tokena ID.", "idpJmespathAboutDescriptionLink": "Dowiedz się więcej o JMESPath", "idpJmespathLabel": "Ścieżka identyfikatora", "idpJmespathLabelDescription": "JMESPath do identyfikatora użytkownika w tokenie ID", "idpJmespathEmailPathOptional": "Ścieżka email (Opcjonalnie)", "idpJmespathEmailPathOptionalDescription": "JMESPath do emaila użytkownika w tokenie ID", "idpJmespathNamePathOptional": "Ścieżka nazwy (Opcjonalnie)", "idpJmespathNamePathOptionalDescription": "JMESPath do nazwy użytkownika w tokenie ID", "idpOidcConfigureScopes": "Zakresy", "idpOidcConfigureScopesDescription": "Lista zakresów OAuth2 oddzielonych spacjami do żądania", "idpSubmit": "Utwórz dostawcę tożsamości", "orgPolicies": "Polityki organizacji", "idpSettings": "Ustawienia {idpName}", "idpCreateSettingsDescription": "Skonfiguruj ustawienia dostawcy tożsamości", "roleMapping": "Mapowanie ról", "orgMapping": "Mapowanie organizacji", "orgPoliciesSearch": "Szukaj polityk organizacji...", "orgPoliciesAdd": "Dodaj politykę organizacji", "orgRequired": "Organizacja jest wymagana", "error": "Błąd", "success": "Sukces", "orgPolicyAddedDescription": "Polityka została pomyślnie dodana", "orgPolicyUpdatedDescription": "Polityka została pomyślnie zaktualizowana", "orgPolicyDeletedDescription": "Polityka została pomyślnie usunięta", "defaultMappingsUpdatedDescription": "Domyślne mapowania zostały pomyślnie zaktualizowane", "orgPoliciesAbout": "O politykach organizacji", "orgPoliciesAboutDescription": "Polityki organizacji służą do kontroli dostępu do organizacji na podstawie tokena ID użytkownika. Możesz określić wyrażenia JMESPath do wydobywania informacji o roli i organizacji z tokena ID. Aby dowiedzieć się więcej, zobacz", "orgPoliciesAboutDescriptionLink": "dokumentację", "defaultMappingsOptional": "Domyślne mapowania (Opcjonalne)", "defaultMappingsOptionalDescription": "Domyślne mapowania są używane, gdy nie ma zdefiniowanej polityki organizacji dla organizacji. Możesz tutaj określić domyślne mapowania ról i organizacji.", "defaultMappingsRole": "Domyślne mapowanie roli", "defaultMappingsRoleDescription": "JMESPath do wydobycia informacji o roli z tokena ID. Wynik tego wyrażenia musi zwrócić nazwę roli zdefiniowaną w organizacji jako ciąg znaków.", "defaultMappingsOrg": "Domyślne mapowanie organizacji", "defaultMappingsOrgDescription": "JMESPath do wydobycia informacji o organizacji z tokena ID. To wyrażenie musi zwrócić ID organizacji lub true, aby użytkownik mógł uzyskać dostęp do organizacji.", "defaultMappingsSubmit": "Zapisz domyślne mapowania", "orgPoliciesEdit": "Edytuj politykę organizacji", "org": "Organizacja", "orgSelect": "Wybierz organizację", "orgSearch": "Szukaj organizacji", "orgNotFound": "Nie znaleziono organizacji.", "roleMappingPathOptional": "Ścieżka mapowania roli (Opcjonalnie)", "orgMappingPathOptional": "Ścieżka mapowania organizacji (Opcjonalnie)", "orgPolicyUpdate": "Aktualizuj politykę", "orgPolicyAdd": "Dodaj politykę", "orgPolicyConfig": "Skonfiguruj dostęp dla organizacji", "idpUpdatedDescription": "Dostawca tożsamości został pomyślnie zaktualizowany", "redirectUrl": "URL przekierowania", "orgIdpRedirectUrls": "Przekieruj adresy URL", "redirectUrlAbout": "O URL przekierowania", "redirectUrlAboutDescription": "Jest to adres URL, na który użytkownicy zostaną przekierowani po uwierzytelnieniu. Musisz skonfigurować ten adres URL w ustawieniach dostawcy tożsamości.", "pangolinAuth": "Autoryzacja - Pangolin", "verificationCodeLengthRequirements": "Twój kod weryfikacyjny musi mieć 8 znaków.", "errorOccurred": "Wystąpił błąd", "emailErrorVerify": "Nie udało się zweryfikować adresu e-mail:", "emailVerified": "E-mail został pomyślnie zweryfikowany! Przekierowywanie...", "verificationCodeErrorResend": "Nie udało się ponownie wysłać kodu weryfikacyjnego:", "verificationCodeResend": "Kod weryfikacyjny wysłany ponownie", "verificationCodeResendDescription": "Wysłaliśmy ponownie kod weryfikacyjny na Twój adres e-mail. Sprawdź swoją skrzynkę odbiorczą.", "emailVerify": "Zweryfikuj e-mail", "emailVerifyDescription": "Wprowadź kod weryfikacyjny wysłany na Twój adres e-mail.", "verificationCode": "Kod weryfikacyjny", "verificationCodeEmailSent": "Wysłaliśmy kod weryfikacyjny na Twój adres e-mail.", "submit": "Wyślij", "emailVerifyResendProgress": "Ponowne wysyłanie...", "emailVerifyResend": "Nie otrzymałeś kodu? Kliknij tutaj, aby wysłać ponownie", "passwordNotMatch": "Hasła nie są zgodne", "signupError": "Wystąpił błąd podczas rejestracji", "pangolinLogoAlt": "Logo Pangolin", "inviteAlready": "Wygląda na to, że zostałeś już zaproszony!", "inviteAlreadyDescription": "Aby zaakceptować zaproszenie, musisz się zalogować lub utworzyć konto.", "signupQuestion": "Masz już konto?", "login": "Zaloguj się", "resourceNotFound": "Nie znaleziono zasobu", "resourceNotFoundDescription": "Zasób, do którego próbujesz uzyskać dostęp, nie istnieje.", "pincodeRequirementsLength": "PIN musi składać się dokładnie z 6 cyfr", "pincodeRequirementsChars": "PIN może zawierać tylko cyfry", "passwordRequirementsLength": "Hasło musi mieć co najmniej 1 znak", "passwordRequirementsTitle": "Wymagania dotyczące hasła:", "passwordRequirementLength": "Przynajmniej 8 znaków długości", "passwordRequirementUppercase": "Przynajmniej jedna wielka litera", "passwordRequirementLowercase": "Przynajmniej jedna mała litera", "passwordRequirementNumber": "Przynajmniej jedna cyfra", "passwordRequirementSpecial": "Przynajmniej jeden znak specjalny", "passwordRequirementsMet": "✓ Hasło spełnia wszystkie wymagania", "passwordStrength": "Siła hasła", "passwordStrengthWeak": "Słabe", "passwordStrengthMedium": "Średnie", "passwordStrengthStrong": "Silne", "passwordRequirements": "Wymagania:", "passwordRequirementLengthText": "8+ znaków", "passwordRequirementUppercaseText": "Wielka litera (A-Z)", "passwordRequirementLowercaseText": "Mała litera (a-z)", "passwordRequirementNumberText": "Cyfra (0-9)", "passwordRequirementSpecialText": "Znak specjalny (!@#$%...)", "passwordsDoNotMatch": "Hasła nie są zgodne", "otpEmailRequirementsLength": "Kod jednorazowy musi mieć co najmniej 1 znak", "otpEmailSent": "Kod jednorazowy wysłany", "otpEmailSentDescription": "Kod jednorazowy został wysłany na Twój e-mail", "otpEmailErrorAuthenticate": "Nie udało się uwierzytelnić za pomocą e-maila", "pincodeErrorAuthenticate": "Nie udało się uwierzytelnić za pomocą kodu PIN", "passwordErrorAuthenticate": "Nie udało się uwierzytelnić za pomocą hasła", "poweredBy": "Obsługiwane przez", "authenticationRequired": "Wymagane uwierzytelnienie", "authenticationMethodChoose": "Wybierz preferowaną metodę dostępu do {name}", "authenticationRequest": "Musisz się uwierzytelnić, aby uzyskać dostęp do {name}", "user": "Użytkownik", "pincodeInput": "6-cyfrowy kod PIN", "pincodeSubmit": "Zaloguj się kodem PIN", "passwordSubmit": "Zaloguj się hasłem", "otpEmailDescription": "Kod jednorazowy zostanie wysłany na ten adres e-mail.", "otpEmailSend": "Wyślij kod jednorazowy", "otpEmail": "Hasło jednorazowe (OTP)", "otpEmailSubmit": "Wyślij OTP", "backToEmail": "Powrót do e-maila", "noSupportKey": "Serwer działa bez klucza wspierającego. Rozważ wsparcie projektu!", "accessDenied": "Odmowa dostępu", "accessDeniedDescription": "Nie masz uprawnień dostępu do tego zasobu. Jeśli to pomyłka, skontaktuj się z administratorem.", "accessTokenError": "Błąd sprawdzania tokena dostępu", "accessGranted": "Dostęp przyznany", "accessUrlInvalid": "Nieprawidłowy URL dostępu", "accessGrantedDescription": "Otrzymałeś dostęp do tego zasobu. Przekierowywanie...", "accessUrlInvalidDescription": "Ten udostępniony URL dostępu jest nieprawidłowy. Skontaktuj się z właścicielem zasobu, aby otrzymać nowy URL.", "tokenInvalid": "Nieprawidłowy token", "pincodeInvalid": "Nieprawidłowy kod", "passwordErrorRequestReset": "Nie udało się zażądać resetowania:", "passwordErrorReset": "Nie udało się zresetować hasła:", "passwordResetSuccess": "Hasło zostało pomyślnie zresetowane! Powrót do logowania...", "passwordReset": "Zresetuj hasło", "passwordResetDescription": "Wykonaj kroki, aby zresetować hasło", "passwordResetSent": "Wyślemy kod resetowania hasła na ten adres e-mail.", "passwordResetCode": "Kod resetowania", "passwordResetCodeDescription": "Sprawdź swój e-mail, aby znaleźć kod resetowania.", "generatePasswordResetCode": "Generuj kod resetowania hasła", "passwordResetCodeGenerated": "Wygenerowany kod resetowania hasła", "passwordResetCodeGeneratedDescription": "Udostępnij ten kod użytkownikowi. Mogą go użyć do zresetowania hasła.", "passwordResetUrl": "Reset URL", "passwordNew": "Nowe hasło", "passwordNewConfirm": "Potwierdź nowe hasło", "changePassword": "Zmień hasło", "changePasswordDescription": "Zaktualizuj hasło do konta", "oldPassword": "Bieżące hasło", "newPassword": "Nowe hasło", "confirmNewPassword": "Potwierdź nowe hasło", "changePasswordError": "Nie udało się zmienić hasła", "changePasswordErrorDescription": "Wystąpił błąd podczas zmiany hasła", "changePasswordSuccess": "Hasło zostało pomyślnie zmienione", "changePasswordSuccessDescription": "Twoje hasło zostało pomyślnie zaktualizowane", "passwordExpiryRequired": "Wymagane hasło wygasające", "passwordExpiryDescription": "Organizacja wymaga zmiany hasła co {maxDays} dni.", "changePasswordNow": "Zmień hasło teraz", "pincodeAuth": "Kod uwierzytelniający", "pincodeSubmit2": "Prześlij kod", "passwordResetSubmit": "Zażądaj resetowania", "passwordResetAlreadyHaveCode": "Wprowadź kod", "passwordResetSmtpRequired": "Skontaktuj się z administratorem", "passwordResetSmtpRequiredDescription": "Aby zresetować hasło, wymagany jest kod resetowania hasła. Skontaktuj się z administratorem.", "passwordBack": "Powrót do hasła", "loginBack": "Wróć do strony logowania głównego", "signup": "Zarejestruj się", "loginStart": "Zaloguj się, aby rozpocząć", "idpOidcTokenValidating": "Walidacja tokena OIDC", "idpOidcTokenResponse": "Zweryfikuj odpowiedź tokena OIDC", "idpErrorOidcTokenValidating": "Błąd walidacji tokena OIDC", "idpConnectingTo": "Łączenie z {name}", "idpConnectingToDescription": "Weryfikacja tożsamości", "idpConnectingToProcess": "Łączenie...", "idpConnectingToFinished": "Połączono", "idpErrorConnectingTo": "Wystąpił problem z połączeniem z {name}. Skontaktuj się z administratorem.", "idpErrorNotFound": "Nie znaleziono IdP", "inviteInvalid": "Nieprawidłowe zaproszenie", "inviteInvalidDescription": "Link zapraszający jest nieprawidłowy.", "inviteErrorWrongUser": "Zaproszenie nie jest dla tego użytkownika", "inviteErrorUserNotExists": "Użytkownik nie istnieje. Najpierw utwórz konto.", "inviteErrorLoginRequired": "Musisz być zalogowany, aby zaakceptować zaproszenie", "inviteErrorExpired": "Zaproszenie mogło wygasnąć", "inviteErrorRevoked": "Zaproszenie mogło zostać odwołane", "inviteErrorTypo": "W linku zapraszającym może być literówka", "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.", "overview": "Przegląd", "home": "Strona główna", "settings": "Ustawienia", "usersAll": "Wszyscy użytkownicy", "license": "Licencja", "pangolinDashboard": "Panel - Pangolin", "noResults": "Nie znaleziono wyników.", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "Wprowadzone tagi", "tagsEnteredDescription": "To są wprowadzone przez ciebie tagi.", "tagsWarnCannotBeLessThanZero": "maxTags i minTags nie mogą być mniejsze od 0", "tagsWarnNotAllowedAutocompleteOptions": "Tag niedozwolony zgodnie z opcjami autouzupełniania", "tagsWarnInvalid": "Nieprawidłowy tag według validateTag", "tagWarnTooShort": "Tag {tagText} jest za krótki", "tagWarnTooLong": "Tag {tagText} jest za długi", "tagsWarnReachedMaxNumber": "Osiągnięto maksymalną dozwoloną liczbę tagów", "tagWarnDuplicate": "Zduplikowany tag {tagText} nie został dodany", "supportKeyInvalid": "Nieprawidłowy klucz", "supportKeyInvalidDescription": "Twój klucz wspierający jest nieprawidłowy.", "supportKeyValid": "Prawidłowy klucz", "supportKeyValidDescription": "Twój klucz wspierający został zweryfikowany. Dziękujemy za wsparcie!", "supportKeyErrorValidationDescription": "Nie udało się zweryfikować klucza wspierającego.", "supportKey": "Wesprzyj rozwój i adoptuj Pangolina!", "supportKeyDescription": "Kup klucz wspierający, aby pomóc nam w dalszym rozwijaniu Pangolina dla społeczności. Twój wkład pozwala nam poświęcić więcej czasu na utrzymanie i dodawanie nowych funkcji do aplikacji dla wszystkich. Nigdy nie wykorzystamy tego do blokowania funkcji za paywallem. Jest to oddzielne od wydania komercyjnego.", "supportKeyPet": "Będziesz mógł także zaadoptować i poznać swojego własnego zwierzaka Pangolina!", "supportKeyPurchase": "Płatności są przetwarzane przez GitHub. Następnie możesz pobrać swój klucz na", "supportKeyPurchaseLink": "naszej stronie", "supportKeyPurchase2": "i wykorzystać go tutaj.", "supportKeyLearnMore": "Dowiedz się więcej.", "supportKeyOptions": "Wybierz opcję, która najbardziej ci odpowiada.", "supportKetOptionFull": "Pełne wsparcie", "forWholeServer": "Dla całego serwera", "lifetimePurchase": "Zakup dożywotni", "supporterStatus": "Status wspierającego", "buy": "Kup", "supportKeyOptionLimited": "Ograniczone wsparcie", "forFiveUsers": "Dla 5 lub mniej użytkowników", "supportKeyRedeem": "Wykorzystaj klucz wspierający", "supportKeyHideSevenDays": "Ukryj na 7 dni", "supportKeyEnter": "Wprowadź klucz wspierający", "supportKeyEnterDescription": "Poznaj swojego własnego zwierzaka Pangolina!", "githubUsername": "Nazwa użytkownika GitHub", "supportKeyInput": "Klucz wspierający", "supportKeyBuy": "Kup klucz wspierający", "logoutError": "Błąd podczas wylogowywania", "signingAs": "Zalogowany jako", "serverAdmin": "Administrator serwera", "managedSelfhosted": "Zarządzane Samodzielnie-Hostingowane", "otpEnable": "Włącz uwierzytelnianie dwuskładnikowe", "otpDisable": "Wyłącz uwierzytelnianie dwuskładnikowe", "logout": "Wyloguj się", "licenseTierProfessionalRequired": "Wymagana edycja Professional", "licenseTierProfessionalRequiredDescription": "Ta funkcja jest dostępna tylko w edycji Professional.", "actionGetOrg": "Pobierz organizację", "updateOrgUser": "Aktualizuj użytkownika Org", "createOrgUser": "Utwórz użytkownika Org", "actionUpdateOrg": "Aktualizuj organizację", "actionRemoveInvitation": "Usuń zaproszenie", "actionUpdateUser": "Zaktualizuj użytkownika", "actionGetUser": "Pobierz użytkownika", "actionGetOrgUser": "Pobierz użytkownika organizacji", "actionListOrgDomains": "Lista domen organizacji", "actionGetDomain": "Pobierz domenę", "actionCreateOrgDomain": "Utwórz domenę", "actionUpdateOrgDomain": "Aktualizuj domenę", "actionDeleteOrgDomain": "Usuń domenę", "actionGetDNSRecords": "Pobierz rekordy DNS", "actionRestartOrgDomain": "Zrestartuj domenę", "actionCreateSite": "Utwórz witrynę", "actionDeleteSite": "Usuń witrynę", "actionGetSite": "Pobierz witrynę", "actionListSites": "Lista witryn", "actionApplyBlueprint": "Zastosuj schemat", "actionListBlueprints": "Lista planów", "actionGetBlueprint": "Pobierz plan", "setupToken": "Skonfiguruj token", "setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.", "setupTokenRequired": "Wymagany jest token konfiguracji", "actionUpdateSite": "Aktualizuj witrynę", "actionListSiteRoles": "Lista dozwolonych ról witryny", "actionCreateResource": "Utwórz zasób", "actionDeleteResource": "Usuń zasób", "actionGetResource": "Pobierz zasób", "actionListResource": "Lista zasobów", "actionUpdateResource": "Aktualizuj zasób", "actionListResourceUsers": "Lista użytkowników zasobu", "actionSetResourceUsers": "Ustaw użytkowników zasobu", "actionSetAllowedResourceRoles": "Ustaw dozwolone role zasobu", "actionListAllowedResourceRoles": "Lista dozwolonych ról zasobu", "actionSetResourcePassword": "Ustaw hasło zasobu", "actionSetResourcePincode": "Ustaw kod PIN zasobu", "actionSetResourceEmailWhitelist": "Ustaw białą listę email zasobu", "actionGetResourceEmailWhitelist": "Pobierz białą listę email zasobu", "actionCreateTarget": "Utwórz cel", "actionDeleteTarget": "Usuń cel", "actionGetTarget": "Pobierz cel", "actionListTargets": "Lista celów", "actionUpdateTarget": "Aktualizuj cel", "actionCreateRole": "Utwórz rolę", "actionDeleteRole": "Usuń rolę", "actionGetRole": "Pobierz rolę", "actionListRole": "Lista ról", "actionUpdateRole": "Aktualizuj rolę", "actionListAllowedRoleResources": "Lista dozwolonych zasobów roli", "actionInviteUser": "Zaproś użytkownika", "actionRemoveUser": "Usuń użytkownika", "actionListUsers": "Lista użytkowników", "actionAddUserRole": "Dodaj rolę użytkownika", "actionGenerateAccessToken": "Wygeneruj token dostępu", "actionDeleteAccessToken": "Usuń token dostępu", "actionListAccessTokens": "Lista tokenów dostępu", "actionCreateResourceRule": "Utwórz regułę zasobu", "actionDeleteResourceRule": "Usuń regułę zasobu", "actionListResourceRules": "Lista reguł zasobu", "actionUpdateResourceRule": "Aktualizuj regułę zasobu", "actionListOrgs": "Lista organizacji", "actionCheckOrgId": "Sprawdź ID", "actionCreateOrg": "Utwórz organizację", "actionDeleteOrg": "Usuń organizację", "actionListApiKeys": "Lista kluczy API", "actionListApiKeyActions": "Lista akcji klucza API", "actionSetApiKeyActions": "Ustaw dozwolone akcje klucza API", "actionCreateApiKey": "Utwórz klucz API", "actionDeleteApiKey": "Usuń klucz API", "actionCreateIdp": "Utwórz IDP", "actionUpdateIdp": "Aktualizuj IDP", "actionDeleteIdp": "Usuń IDP", "actionListIdps": "Lista IDP", "actionGetIdp": "Pobierz IDP", "actionCreateIdpOrg": "Utwórz politykę organizacji IDP", "actionDeleteIdpOrg": "Usuń politykę organizacji IDP", "actionListIdpOrgs": "Lista organizacji IDP", "actionUpdateIdpOrg": "Aktualizuj organizację IDP", "actionCreateClient": "Utwórz klienta", "actionDeleteClient": "Usuń klienta", "actionArchiveClient": "Zarchiwizuj klienta", "actionUnarchiveClient": "Usuń archiwizację klienta", "actionBlockClient": "Zablokuj klienta", "actionUnblockClient": "Odblokuj klienta", "actionUpdateClient": "Aktualizuj klienta", "actionListClients": "Lista klientów", "actionGetClient": "Pobierz klienta", "actionCreateSiteResource": "Utwórz zasób witryny", "actionDeleteSiteResource": "Usuń zasób strony", "actionGetSiteResource": "Pobierz zasób strony", "actionListSiteResources": "Lista zasobów strony", "actionUpdateSiteResource": "Aktualizuj zasób strony", "actionListInvitations": "Lista zaproszeń", "actionExportLogs": "Eksportuj dzienniki", "actionViewLogs": "Zobacz dzienniki", "noneSelected": "Nie wybrano", "orgNotFound2": "Nie znaleziono organizacji.", "searchPlaceholder": "Szukaj...", "emptySearchOptions": "Nie znaleziono opcji", "create": "Utwórz", "orgs": "Organizacje", "loginError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.", "loginRequiredForDevice": "Logowanie jest wymagane dla Twojego urządzenia.", "passwordForgot": "Zapomniałeś hasła?", "otpAuth": "Uwierzytelnianie dwuskładnikowe", "otpAuthDescription": "Wprowadź kod z aplikacji uwierzytelniającej lub jeden z jednorazowych kodów zapasowych.", "otpAuthSubmit": "Wyślij kod", "idpContinue": "Lub kontynuuj z", "otpAuthBack": "Powrót do hasła", "navbar": "Menu nawigacyjne", "navbarDescription": "Główne menu nawigacyjne aplikacji", "navbarDocsLink": "Dokumentacja", "otpErrorEnable": "Nie można włączyć 2FA", "otpErrorEnableDescription": "Wystąpił błąd podczas włączania 2FA", "otpSetupCheckCode": "Wprowadź 6-cyfrowy kod", "otpSetupCheckCodeRetry": "Nieprawidłowy kod. Spróbuj ponownie.", "otpSetup": "Włącz uwierzytelnianie dwuskładnikowe", "otpSetupDescription": "Zabezpiecz swoje konto dodatkową warstwą ochrony", "otpSetupScanQr": "Zeskanuj ten kod QR za pomocą aplikacji uwierzytelniającej lub wprowadź klucz tajny ręcznie:", "otpSetupSecretCode": "Kod uwierzytelniający", "otpSetupSuccess": "Włączono uwierzytelnianie dwuskładnikowe", "otpSetupSuccessStoreBackupCodes": "Twoje konto jest teraz bezpieczniejsze. Nie zapomnij zapisać kodów zapasowych.", "otpErrorDisable": "Nie można wyłączyć 2FA", "otpErrorDisableDescription": "Wystąpił błąd podczas wyłączania 2FA", "otpRemove": "Wyłącz uwierzytelnianie dwuskładnikowe", "otpRemoveDescription": "Wyłącz uwierzytelnianie dwuskładnikowe dla swojego konta", "otpRemoveSuccess": "Wyłączono uwierzytelnianie dwuskładnikowe", "otpRemoveSuccessMessage": "Uwierzytelnianie dwuskładnikowe zostało wyłączone dla Twojego konta. Możesz je włączyć ponownie w dowolnym momencie.", "otpRemoveSubmit": "Wyłącz 2FA", "paginator": "Strona {current} z {last}", "paginatorToFirst": "Przejdź do pierwszej strony", "paginatorToPrevious": "Przejdź do poprzedniej strony", "paginatorToNext": "Przejdź do następnej strony", "paginatorToLast": "Przejdź do ostatniej strony", "copyText": "Kopiuj tekst", "copyTextFailed": "Nie udało się skopiować tekstu: ", "copyTextClipboard": "Kopiuj do schowka", "inviteErrorInvalidConfirmation": "Nieprawidłowe potwierdzenie", "passwordRequired": "Hasło jest wymagane", "allowAll": "Zezwól wszystkim", "permissionsAllowAll": "Zezwól na wszystkie uprawnienia", "githubUsernameRequired": "Nazwa użytkownika GitHub jest wymagana", "supportKeyRequired": "Klucz wspierający jest wymagany", "passwordRequirementsChars": "Hasło musi mieć co najmniej 8 znaków", "language": "Język", "verificationCodeRequired": "Kod jest wymagany", "userErrorNoUpdate": "Brak użytkownika do aktualizacji", "siteErrorNoUpdate": "Brak witryny do aktualizacji", "resourceErrorNoUpdate": "Brak zasobu do aktualizacji", "authErrorNoUpdate": "Brak danych uwierzytelniania do aktualizacji", "orgErrorNoUpdate": "Brak organizacji do aktualizacji", "orgErrorNoProvided": "Nie podano organizacji", "apiKeysErrorNoUpdate": "Brak klucza API do aktualizacji", "sidebarOverview": "Przegląd", "sidebarHome": "Strona główna", "sidebarSites": "Witryny", "sidebarApprovals": "Wnioski o zatwierdzenie", "sidebarResources": "Zasoby", "sidebarProxyResources": "Publiczne", "sidebarClientResources": "Prywatny", "sidebarAccessControl": "Kontrola dostępu", "sidebarLogsAndAnalytics": "Logi i Analityki", "sidebarTeam": "Drużyna", "sidebarUsers": "Użytkownicy", "sidebarAdmin": "Administrator", "sidebarInvitations": "Zaproszenia", "sidebarRoles": "Role", "sidebarShareableLinks": "Linki", "sidebarApiKeys": "Klucze API", "sidebarSettings": "Ustawienia", "sidebarAllUsers": "Wszyscy użytkownicy", "sidebarIdentityProviders": "Dostawcy tożsamości", "sidebarLicense": "Licencja", "sidebarClients": "Klienty", "sidebarUserDevices": "Urządzenia użytkownika", "sidebarMachineClients": "Maszyny", "sidebarDomains": "Domeny", "sidebarGeneral": "Zarządzaj", "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", "blueprintAdd": "Dodaj schemat", "blueprintGoBack": "Zobacz wszystkie schematy", "blueprintCreate": "Utwórz schemat", "blueprintCreateDescription2": "Wykonaj poniższe kroki, aby utworzyć i zastosować nowy schemat", "blueprintDetails": "Szczegóły Projektu", "blueprintDetailsDescription": "Zobacz wynik zastosowanego schematu i wszelkie błędy, które wystąpiły", "blueprintInfo": "Informacje o projekcie", "message": "Wiadomość", "blueprintContentsDescription": "Zdefiniuj zawartość YAML opisującą infrastrukturę", "blueprintErrorCreateDescription": "Wystąpił błąd podczas stosowania schematu", "blueprintErrorCreate": "Błąd podczas tworzenia schematu", "searchBlueprintProgress": "Szukaj schematów...", "appliedAt": "Zastosowano", "source": "Źródło", "contents": "Treść", "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.", "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.", "containerName": "Nazwa", "containerImage": "Obraz", "containerState": "Stan", "containerNetworks": "Sieci", "containerHostnameIp": "Nazwa hosta/IP", "containerLabels": "Etykiety", "containerLabelsCount": "{count, plural, one {# etykieta} few {# etykiety} many {# etykiet} other {# etykiet}}", "containerLabelsTitle": "Etykiety kontenera", "containerLabelEmpty": "", "containerPorts": "Porty", "containerPortsMore": "+{count} więcej", "containerActions": "Akcje", "select": "Wybierz", "noContainersMatchingFilters": "Nie znaleziono kontenerów pasujących do obecnych filtrów.", "showContainersWithoutPorts": "Pokaż kontenery bez portów", "showStoppedContainers": "Pokaż zatrzymane kontenery", "noContainersFound": "Nie znaleziono kontenerów. Upewnij się, że kontenery dokujące są uruchomione.", "searchContainersPlaceholder": "Szukaj w {count} kontenerach...", "searchResultsCount": "{count, plural, one {# wynik} few {# wyniki} many {# wyników} other {# wyników}}", "filters": "Filtry", "filterOptions": "Opcje filtru", "filterPorts": "Porty", "filterStopped": "Zatrzymano", "clearAllFilters": "Wyczyść wszystkie filtry", "columns": "Kwota, którą należy zgłosić w kolumnie 060 tego wiersza: pierwotne odliczenie, art. 36 ust. 1 lit. b) CRR.", "toggleColumns": "Przełącz kolumny", "refreshContainersList": "Odśwież listę kontenerów", "searching": "Wyszukiwanie...", "noContainersFoundMatching": "Nie znaleziono kontenerów pasujących do \"{filter}\".", "light": "jasny", "dark": "ciemny", "system": "System", "theme": "Motyw", "subnetRequired": "Podsieć jest wymagana", "initialSetupTitle": "Wstępna konfiguracja serwera", "initialSetupDescription": "Utwórz początkowe konto administratora serwera. Może istnieć tylko jeden administrator serwera. Zawsze można zmienić te dane uwierzytelniające.", "createAdminAccount": "Utwórz konto administratora", "setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.", "certificateStatus": "Status certyfikatu", "loading": "Ładowanie", "loadingAnalytics": "Ładowanie Analityki", "restart": "Uruchom ponownie", "domains": "Domeny", "domainsDescription": "Tworzenie domen dostępnych w organizacji i zarządzanie nimi", "domainsSearch": "Szukaj domen...", "domainAdd": "Dodaj domenę", "domainAddDescription": "Zarejestruj nową domenę w organizacji", "domainCreate": "Utwórz domenę", "domainCreatedDescription": "Domena utworzona pomyślnie", "domainDeletedDescription": "Domena usunięta pomyślnie", "domainQuestionRemove": "Czy na pewno chcesz usunąć domenę?", "domainMessageRemove": "Po usunięciu, domena nie będzie już powiązana z organizacją.", "domainConfirmDelete": "Potwierdź usunięcie domeny", "domainDelete": "Usuń domenę", "domain": "Domena", "selectDomainTypeNsName": "Delegacja domeny (NS)", "selectDomainTypeNsDescription": "Ta domena i wszystkie jej subdomeny. Użyj tego, gdy chcesz kontrolować całą strefę domeny.", "selectDomainTypeCnameName": "Pojedyncza domena (CNAME)", "selectDomainTypeCnameDescription": "Tylko ta pojedyncza domena. Użyj tego dla poszczególnych subdomen lub wpisów specyficznych dla domeny.", "selectDomainTypeWildcardName": "Domena wieloznaczna", "selectDomainTypeWildcardDescription": "Ta domena i jej subdomeny.", "domainDelegation": "Pojedyncza domena", "selectType": "Wybierz typ", "actions": "Akcje", "refresh": "Odśwież", "refreshError": "Nie udało się odświeżyć danych", "verified": "Zatwierdzony", "pending": "Oczekuje", "pendingApproval": "Oczekujące na zatwierdzenie", "sidebarBilling": "Fakturowanie", "billing": "Fakturowanie", "orgBillingDescription": "Zarządzaj informacjami rozliczeniowymi i subskrypcjami", "github": "GitHub", "pangolinHosted": "Logo Pangolin", "fossorial": "Fossorial", "completeAccountSetup": "Zakończ konfigurację konta", "completeAccountSetupDescription": "Ustaw swoje hasło, aby rozpocząć", "accountSetupSent": "Wyślemy kod konfiguracji konta na ten adres e-mail.", "accountSetupCode": "Kod konfiguracji", "accountSetupCodeDescription": "Sprawdź swój e-mail, aby znaleźć kod konfiguracji.", "passwordCreate": "Utwórz hasło", "passwordCreateConfirm": "Potwierdź hasło", "accountSetupSubmit": "Wyślij kod konfiguracji", "completeSetup": "Zakończ konfigurację", "accountSetupSuccess": "Konfiguracja konta zakończona! Witaj w Pangolin!", "documentation": "Dokumentacja", "saveAllSettings": "Zapisz wszystkie ustawienia", "saveResourceTargets": "Zapisz cele", "saveResourceHttp": "Zapisz ustawienia proxy", "saveProxyProtocol": "Zapisz ustawienia protokołu proxy", "settingsUpdated": "Ustawienia zaktualizowane", "settingsUpdatedDescription": "Ustawienia zostały pomyślnie zaktualizowane", "settingsErrorUpdate": "Nie udało się zaktualizować ustawień", "settingsErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji ustawień", "sidebarCollapse": "Zwiń", "sidebarExpand": "Rozwiń", "productUpdateMoreInfo": "{noOfUpdates} więcej aktualizacji", "productUpdateInfo": "Aktualizacje {noOfUpdates}", "productUpdateWhatsNew": "Co nowego", "productUpdateTitle": "Aktualizacje produktu", "productUpdateEmpty": "Brak aktualizacji", "dismissAll": "Zamknij wszystkie", "pangolinUpdateAvailable": "Dostępna aktualizacja", "pangolinUpdateAvailableInfo": "Wersja {version} jest gotowa do zainstalowania", "pangolinUpdateAvailableReleaseNotes": "Zobacz informacje o wydaniu", "newtUpdateAvailable": "Dostępna aktualizacja", "newtUpdateAvailableInfo": "Nowa wersja Newt jest dostępna. Prosimy o aktualizację do najnowszej wersji dla najlepszej pracy.", "domainPickerEnterDomain": "Domena", "domainPickerPlaceholder": "mojapp.example.com", "domainPickerDescription": "Wpisz pełną domenę zasobu, aby zobaczyć dostępne opcje.", "domainPickerDescriptionSaas": "Wprowadź pełną domenę, subdomenę lub po prostu nazwę, aby zobaczyć dostępne opcje", "domainPickerTabAll": "Wszystko", "domainPickerTabOrganization": "Organizacja", "domainPickerTabProvided": "Dostarczona", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Sprawdzanie dostępności...", "domainPickerNoMatchingDomains": "Nie znaleziono pasujących domen. Wypróbuj inną domenę lub sprawdź ustawienia domeny organizacji.", "domainPickerOrganizationDomains": "Domeny organizacji", "domainPickerProvidedDomains": "Dostarczone domeny", "domainPickerSubdomain": "Subdomena: {subdomain}", "domainPickerNamespace": "Przestrzeń nazw: {namespace}", "domainPickerShowMore": "Pokaż więcej", "regionSelectorTitle": "Wybierz region", "regionSelectorInfo": "Wybór regionu pomaga nam zapewnić lepszą wydajność dla Twojej lokalizacji. Nie musisz być w tym samym regionie co Twój serwer.", "regionSelectorPlaceholder": "Wybierz region", "regionSelectorComingSoon": "Wkrótce dostępne", "billingLoadingSubscription": "Ładowanie subskrypcji...", "billingFreeTier": "Darmowy pakiet", "billingWarningOverLimit": "Ostrzeżenie: Przekroczyłeś jeden lub więcej limitów użytkowania. Twoje witryny nie połączą się, dopóki nie zmienisz subskrypcji lub nie dostosujesz użytkowania.", "billingUsageLimitsOverview": "Przegląd Limitów Użytkowania", "billingMonitorUsage": "Monitoruj swoje wykorzystanie w porównaniu do skonfigurowanych limitów. Jeśli potrzebujesz zwiększenia limitów, skontaktuj się z nami pod adresem support@pangolin.net.", "billingDataUsage": "Użycie danych", "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", "billingIncludedUsage": "Zawarte użycie", "billingIncludedUsageDescription": "Użycie zawarte w obecnym planie subskrypcji", "billingFreeTierIncludedUsage": "Limity użycia dla darmowego pakietu", "billingIncluded": "zawarte", "billingEstimatedTotal": "Szacowana Całkowita:", "billingNotes": "Notatki", "billingEstimateNote": "To jest szacunkowe, oparte na Twoim obecnym użyciu.", "billingActualChargesMayVary": "Rzeczywiste opłaty mogą się różnić.", "billingBilledAtEnd": "Zostaniesz obciążony na koniec okresu rozliczeniowego.", "billingModifySubscription": "Modyfikuj Subskrypcję", "billingStartSubscription": "Rozpocznij Subskrypcję", "billingRecurringCharge": "Opłata Cyklowa", "billingManageSubscriptionSettings": "Zarządzaj ustawieniami i preferencjami subskrypcji", "billingNoActiveSubscription": "Nie masz aktywnej subskrypcji. Rozpocznij subskrypcję, aby zwiększyć limity użytkowania.", "billingFailedToLoadSubscription": "Nie udało się załadować subskrypcji", "billingFailedToLoadUsage": "Nie udało się załadować użycia", "billingFailedToGetCheckoutUrl": "Nie udało się uzyskać adresu URL zakupu", "billingPleaseTryAgainLater": "Spróbuj ponownie później.", "billingCheckoutError": "Błąd przy kasie", "billingFailedToGetPortalUrl": "Nie udało się uzyskać adresu URL portalu", "billingPortalError": "Błąd Portalu", "billingDataUsageInfo": "Jesteś obciążony za wszystkie dane przesyłane przez bezpieczne tunele, gdy jesteś podłączony do chmury. Obejmuje to zarówno ruch przychodzący, jak i wychodzący we wszystkich Twoich witrynach. Gdy osiągniesz swój limit, twoje strony zostaną rozłączone, dopóki nie zaktualizujesz planu lub nie ograniczysz użycia. Dane nie będą naliczane przy użyciu węzłów.", "billingSInfo": "Ile stron możesz użyć", "billingUsersInfo": "Ile użytkowników możesz użyć", "billingDomainInfo": "Ile domen możesz użyć", "billingRemoteExitNodesInfo": "Ile zdalnych węzłów możesz użyć", "billingLicenseKeys": "Klucze licencyjne", "billingLicenseKeysDescription": "Zarządzaj subskrypcjami kluczy licencyjnych", "billingLicenseSubscription": "Subskrypcja licencji", "billingInactive": "Nieaktywny", "billingLicenseItem": "Element licencji", "billingQuantity": "Ilość", "billingTotal": "łącznie", "billingModifyLicenses": "Modyfikuj subskrypcję licencji", "domainNotFound": "Nie znaleziono domeny", "domainNotFoundDescription": "Zasób jest wyłączony, ponieważ domena nie istnieje już w naszym systemie. Proszę ustawić nową domenę dla tego zasobu.", "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", "securityKeyRegister": "Zarejestruj nowy klucz bezpieczeństwa", "securityKeyList": "Twoje klucze bezpieczeństwa", "securityKeyNone": "Brak zarejestrowanych kluczy bezpieczeństwa", "securityKeyNameRequired": "Nazwa jest wymagana", "securityKeyRemove": "Usuń", "securityKeyLastUsed": "Ostatnio używany: {date}", "securityKeyNameLabel": "Nazwa", "securityKeyRegisterSuccess": "Klucz bezpieczeństwa został pomyślnie zarejestrowany", "securityKeyRegisterError": "Błąd podczas rejestracji klucza bezpieczeństwa", "securityKeyRemoveSuccess": "Klucz bezpieczeństwa został pomyślnie usunięty", "securityKeyRemoveError": "Błąd podczas usuwania klucza bezpieczeństwa", "securityKeyLoadError": "Błąd podczas ładowania kluczy bezpieczeństwa", "securityKeyLogin": "Użyj klucza bezpieczeństwa", "securityKeyAuthError": "Błąd podczas uwierzytelniania kluczem bezpieczeństwa", "securityKeyRecommendation": "Rozważ zarejestrowanie innego klucza bezpieczeństwa na innym urządzeniu, aby upewnić się, że nie zostaniesz zablokowany z dostępu do swojego konta.", "registering": "Rejestracja...", "securityKeyPrompt": "Proszę zweryfikować swoją tożsamość, używając klucza bezpieczeństwa. Upewnij się, że twój klucz bezpieczeństwa jest podłączony i gotowy.", "securityKeyBrowserNotSupported": "Twoja przeglądarka nie obsługuje kluczy bezpieczeństwa. Proszę użyć nowoczesnej przeglądarki, takiej jak Chrome, Firefox lub Safari.", "securityKeyPermissionDenied": "Proszę umożliwić dostęp do klucza bezpieczeństwa, aby kontynuować logowanie.", "securityKeyRemovedTooQuickly": "Proszę utrzymać klucz bezpieczeństwa podłączony, dopóki proces logowania się nie zakończy.", "securityKeyNotSupported": "Twój klucz bezpieczeństwa może być niekompatybilny. Proszę spróbować innego klucza bezpieczeństwa.", "securityKeyUnknownError": "Wystąpił problem z używaniem klucza bezpieczeństwa. Proszę spróbować ponownie.", "twoFactorRequired": "Uwierzytelnianie dwuskładnikowe jest wymagane do zarejestrowania klucza bezpieczeństwa.", "twoFactor": "Uwierzytelnianie dwuskładnikowe", "twoFactorAuthentication": "Uwierzytelnianie dwuetapowe", "twoFactorDescription": "Ta organizacja wymaga uwierzytelniania dwuskładnikowego.", "enableTwoFactor": "Włącz uwierzytelnianie dwuetapowe", "organizationSecurityPolicy": "Polityka bezpieczeństwa organizacji", "organizationSecurityPolicyDescription": "Ta organizacja ma wymagania bezpieczeństwa, które muszą być spełnione, zanim będziesz mógł uzyskać dostęp do niej", "securityRequirements": "Wymogi bezpieczeństwa", "allRequirementsMet": "Wszystkie wymagania zostały spełnione", "completeRequirementsToContinue": "Wypełnij poniższe wymagania, aby kontynuować dostęp do tej organizacji", "youCanNowAccessOrganization": "Teraz możesz uzyskać dostęp do tej organizacji", "reauthenticationRequired": "Długość sesji", "reauthenticationDescription": "Organizacja wymaga logowania co {maxDays} dni.", "reauthenticationDescriptionHours": "Organizacja wymaga logowania co {maxHours} godzin.", "reauthenticateNow": "Zaloguj się ponownie", "adminEnabled2FaOnYourAccount": "Twój administrator włączył uwierzytelnianie dwuskładnikowe dla {email}. Proszę ukończyć proces konfiguracji, aby kontynuować.", "securityKeyAdd": "Dodaj klucz bezpieczeństwa", "securityKeyRegisterTitle": "Zarejestruj nowy klucz bezpieczeństwa", "securityKeyRegisterDescription": "Podłącz swój klucz bezpieczeństwa i wprowadź nazwę, aby go zidentyfikować", "securityKeyTwoFactorRequired": "Wymagane uwierzytelnianie dwuskładnikowe", "securityKeyTwoFactorDescription": "Proszę wprowadzić kod uwierzytelnienia dwuskładnikowego, aby zarejestrować klucz bezpieczeństwa", "securityKeyTwoFactorRemoveDescription": "Proszę wprowadzić kod uwierzytelnienia dwuskładnikowego, aby usunąć klucz bezpieczeństwa", "securityKeyTwoFactorCode": "Kod dwuskładnikowy", "securityKeyRemoveTitle": "Usuń klucz bezpieczeństwa", "securityKeyRemoveDescription": "Wprowadź hasło, aby usunąć klucz bezpieczeństwa \"{name}\"", "securityKeyNoKeysRegistered": "Nie zarejestrowano kluczy bezpieczeństwa", "securityKeyNoKeysDescription": "Dodaj klucz bezpieczeństwa, aby zwiększyć swoje zabezpieczenia konta", "createDomainRequired": "Domena jest wymagana", "createDomainAddDnsRecords": "Dodaj rekordy DNS", "createDomainAddDnsRecordsDescription": "Dodaj poniższe rekordy DNS do swojego dostawcy domeny, aby zakończyć konfigurację.", "createDomainNsRecords": "Rekordy NS", "createDomainRecord": "Rekord", "createDomainType": "Typ:", "createDomainName": "Nazwa:", "createDomainValue": "Wartość:", "createDomainCnameRecords": "Rekordy CNAME", "createDomainARecords": "Rekordy A", "createDomainRecordNumber": "Rekord {number}", "createDomainTxtRecords": "Rekordy TXT", "createDomainSaveTheseRecords": "Zapisz te rekordy", "createDomainSaveTheseRecordsDescription": "Upewnij się, że zapiszesz te rekordy DNS, ponieważ nie będziesz mieć ich ponownie na ekranie.", "createDomainDnsPropagation": "Propagacja DNS", "createDomainDnsPropagationDescription": "Zmiany DNS mogą zająć trochę czasu na rozpropagowanie się w Internecie. Może to potrwać od kilku minut do 48 godzin, w zależności od dostawcy DNS i ustawień TTL.", "resourcePortRequired": "Numer portu jest wymagany dla zasobów non-HTTP", "resourcePortNotAllowed": "Numer portu nie powinien być ustawiony dla zasobów HTTP", "billingPricingCalculatorLink": "Kalkulator Cen", "billingYourPlan": "Twój plan", "billingViewOrModifyPlan": "Wyświetl lub zmodyfikuj swój aktualny plan", "billingViewPlanDetails": "Zobacz szczegóły planu", "billingUsageAndLimits": "Stosowanie i ograniczenia", "billingViewUsageAndLimits": "Zobacz limity swojego planu i bieżące użycie", "billingCurrentUsage": "Bieżące użycie", "billingMaximumLimits": "Maksymalne limity", "billingRemoteNodes": "Zdalne węzły", "billingUnlimited": "Nieograniczona", "billingPaidLicenseKeys": "Płatne klucze licencyjne", "billingManageLicenseSubscription": "Zarządzaj subskrypcją płatnych własnych kluczy licencyjnych", "billingCurrentKeys": "Bieżące klucze", "billingModifyCurrentPlan": "Modyfikuj bieżący plan", "billingConfirmUpgrade": "Potwierdź aktualizację", "billingConfirmDowngrade": "Potwierdź obniżenie", "billingConfirmUpgradeDescription": "Zamierzasz ulepszyć swój plan. Przejrzyj nowe limity i ceny poniżej.", "billingConfirmDowngradeDescription": "Zamierzasz obniżyć swój plan. Przejrzyj nowe limity i ceny poniżej.", "billingPlanIncludes": "Plan zawiera", "billingProcessing": "Przetwarzanie...", "billingConfirmUpgradeButton": "Potwierdź aktualizację", "billingConfirmDowngradeButton": "Potwierdź obniżenie", "billingLimitViolationWarning": "Użycie przekracza nowe limity planu", "billingLimitViolationDescription": "Bieżące użycie przekracza limity tego planu. Po obniżeniu, wszystkie działania zostaną wyłączone, dopóki nie zmniejsz zużycia w ramach nowych limitów. Zapoznaj się z poniższymi funkcjami, które obecnie przekraczają limity. Limity naruszenia:", "billingFeatureLossWarning": "Powiadomienie o dostępności funkcji", "billingFeatureLossDescription": "Po obniżeniu wartości funkcje niedostępne w nowym planie zostaną automatycznie wyłączone. Niektóre ustawienia i konfiguracje mogą zostać utracone. Zapoznaj się z matrycą cenową, aby zrozumieć, które funkcje nie będą już dostępne.", "billingUsageExceedsLimit": "Bieżące użycie ({current}) przekracza limit ({limit})", "billingPastDueTitle": "Płatność w przeszłości", "billingPastDueDescription": "Twoja płatność jest zaległa. Zaktualizuj metodę płatności, aby kontynuować korzystanie z funkcji aktualnego planu. Jeśli nie zostanie rozwiązana, Twoja subskrypcja zostanie anulowana i zostaniesz przywrócony do darmowego poziomu.", "billingUnpaidTitle": "Subskrypcja niezapłacona", "billingUnpaidDescription": "Twoja subskrypcja jest niezapłacona i została przywrócona do darmowego poziomu. Zaktualizuj swoją metodę płatności, aby przywrócić subskrypcję.", "billingIncompleteTitle": "Płatność niezakończona", "billingIncompleteDescription": "Twoja płatność jest niekompletna. Ukończ proces płatności, aby aktywować subskrypcję.", "billingIncompleteExpiredTitle": "Płatność wygasła", "billingIncompleteExpiredDescription": "Twoja płatność nigdy nie została zakończona i wygasła. Zostałeś przywrócony do darmowego poziomu. Zapisz się ponownie, aby przywrócić dostęp do płatnych funkcji.", "billingManageSubscription": "Zarządzaj subskrypcją", "billingResolvePaymentIssue": "Rozwiąż problem z płatnościami przed aktualizacją lub obniżeniem oceny", "signUpTerms": { "IAgreeToThe": "Zgadzam się z", "termsOfService": "warunkami usługi", "and": "oraz", "privacyPolicy": "polityka prywatności." }, "signUpMarketing": { "keepMeInTheLoop": "Zachowaj mnie w pętli z wiadomościami, aktualizacjami i nowymi funkcjami przez e-mail." }, "siteRequired": "Strona jest wymagana.", "olmTunnel": "Tunel Olm", "olmTunnelDescription": "Użyj Olm do łączności klienta", "errorCreatingClient": "Błąd podczas tworzenia klienta", "clientDefaultsNotFound": "Nie znaleziono domyślnych ustawień klienta", "createClient": "Utwórz Klienta", "createClientDescription": "Utwórz nowego klienta, aby uzyskać dostęp do prywatnych zasobów", "seeAllClients": "Zobacz Wszystkich Klientów", "clientInformation": "Informacje o Kliencie", "clientNamePlaceholder": "Nazwa klienta", "address": "Adres", "subnetPlaceholder": "Podsieć", "addressDescription": "Adres wewnętrzny klienta. Musi mieścić się w podsieci organizacji.", "selectSites": "Wybierz witryny", "sitesDescription": "Klient będzie miał łączność z wybranymi witrynami", "clientInstallOlm": "Zainstaluj Olm", "clientInstallOlmDescription": "Uruchom Olm na swoim systemie", "clientOlmCredentials": "Dane logowania", "clientOlmCredentialsDescription": "W ten sposób klient będzie uwierzytelniał się z serwerem", "olmEndpoint": "Endpoint", "olmId": "ID", "olmSecretKey": "Sekret", "clientCredentialsSave": "Zapisz dane logowania", "clientCredentialsSaveDescription": "Będziesz mógł zobaczyć to tylko raz. Upewnij się, że skopiujesz go w bezpieczne miejsce.", "generalSettingsDescription": "Skonfiguruj ogólne ustawienia dla tego klienta", "clientUpdated": "Klient zaktualizowany", "clientUpdatedDescription": "Klient został zaktualizowany.", "clientUpdateFailed": "Nie udało się zaktualizować klienta", "clientUpdateError": "Wystąpił błąd podczas aktualizacji klienta.", "sitesFetchFailed": "Nie udało się pobrać witryn", "sitesFetchError": "Wystąpił błąd podczas pobierania witryn.", "olmErrorFetchReleases": "Wystąpił błąd podczas pobierania wydań Olm.", "olmErrorFetchLatest": "Wystąpił błąd podczas pobierania najnowszego wydania Olm.", "enterCidrRange": "Wprowadź zakres CIDR", "resourceEnableProxy": "Włącz publiczny proxy", "resourceEnableProxyDescription": "Włącz publiczne proxy dla tego zasobu. To umożliwia dostęp do zasobu spoza sieci przez chmurę na otwartym porcie. Wymaga konfiguracji Traefik.", "externalProxyEnabled": "Zewnętrzny Proxy Włączony", "addNewTarget": "Dodaj nowy cel", "targetsList": "Lista celów", "advancedMode": "Tryb zaawansowany", "advancedSettings": "Zaawansowane ustawienia", "targetErrorDuplicateTargetFound": "Znaleziono duplikat celu", "healthCheckHealthy": "Zdrowy", "healthCheckUnhealthy": "Niezdrowy", "healthCheckUnknown": "Nieznany", "healthCheck": "Kontrola Zdrowia", "configureHealthCheck": "Skonfiguruj Kontrolę Zdrowia", "configureHealthCheckDescription": "Skonfiguruj monitorowanie zdrowia dla {target}", "enableHealthChecks": "Włącz Kontrole Zdrowia", "enableHealthChecksDescription": "Monitoruj zdrowie tego celu. Możesz monitorować inny punkt końcowy niż docelowy w razie potrzeby.", "healthScheme": "Metoda", "healthSelectScheme": "Wybierz metodę", "healthCheckPortInvalid": "Port oceny stanu musi znajdować się między 1 a 65535", "healthCheckPath": "Ścieżka", "healthHostname": "IP / Nazwa hosta", "healthPort": "Port", "healthCheckPathDescription": "Ścieżka do sprawdzania stanu zdrowia.", "healthyIntervalSeconds": "Odstęp zdrowego (sek)", "unhealthyIntervalSeconds": "Niezdrowy interwał (sek)", "IntervalSeconds": "Interwał Zdrowy", "timeoutSeconds": "Limit czasu (sek)", "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ń oddzielonych przecinkami, które użytkownik może uruchamiać z sudo.", "sshCreateHomeDir": "Utwórz katalog domowy", "sshUnixGroups": "Grupy Unix", "sshUnixGroupsDescription": "Oddzielone przecinkami grupy Unix, aby dodać 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.", "customHeaders": "Niestandardowe nagłówki", "customHeadersDescription": "Nagłówki oddzielone: Nazwa nagłówka: wartość", "headersValidationError": "Nagłówki muszą być w formacie: Nazwa nagłówka: wartość.", "saveHealthCheck": "Zapisz Kontrolę Zdrowia", "healthCheckSaved": "Kontrola Zdrowia Zapisana", "healthCheckSavedDescription": "Konfiguracja kontroli zdrowia została zapisana pomyślnie", "healthCheckError": "Błąd Kontroli Zdrowia", "healthCheckErrorDescription": "Wystąpił błąd podczas zapisywania konfiguracji kontroli zdrowia", "healthCheckPathRequired": "Ścieżka kontroli zdrowia jest wymagana", "healthCheckMethodRequired": "Metoda HTTP jest wymagana", "healthCheckIntervalMin": "Interwał sprawdzania musi wynosić co najmniej 5 sekund", "healthCheckTimeoutMin": "Limit czasu musi wynosić co najmniej 1 sekundę", "healthCheckRetryMin": "Liczba prób ponowienia musi wynosić co najmniej 1", "httpMethod": "Metoda HTTP", "selectHttpMethod": "Wybierz metodę HTTP", "domainPickerSubdomainLabel": "Poddomena", "domainPickerBaseDomainLabel": "Domen bazowa", "domainPickerSearchDomains": "Szukaj domen...", "domainPickerNoDomainsFound": "Nie znaleziono domen", "domainPickerLoadingDomains": "Ładowanie domen...", "domainPickerSelectBaseDomain": "Wybierz domenę bazową...", "domainPickerNotAvailableForCname": "Niedostępne dla domen CNAME", "domainPickerEnterSubdomainOrLeaveBlank": "Wprowadź poddomenę lub pozostaw puste, aby użyć domeny bazowej.", "domainPickerEnterSubdomainToSearch": "Wprowadź poddomenę, aby wyszukać i wybrać z dostępnych darmowych domen.", "domainPickerFreeDomains": "Darmowe domeny", "domainPickerSearchForAvailableDomains": "Szukaj dostępnych domen", "domainPickerNotWorkSelfHosted": "Uwaga: Darmowe domeny nie są obecnie dostępne dla instancji samodzielnie-hostowanych.", "resourceDomain": "Domena", "resourceEditDomain": "Edytuj domenę", "siteName": "Nazwa strony", "proxyPort": "Port", "resourcesTableProxyResources": "Publiczne", "resourcesTableClientResources": "Prywatny", "resourcesTableNoProxyResourcesFound": "Nie znaleziono zasobów proxy.", "resourcesTableNoInternalResourcesFound": "Nie znaleziono wewnętrznych zasobów.", "resourcesTableDestination": "Miejsce docelowe", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Adres aliasu", "resourcesTableAliasAddressInfo": "Ten adres jest częścią podsieci użyteczności organizacji. Jest używany do rozwiązywania rekordów aliasu przy użyciu wewnętrznej rozdzielczości DNS.", "resourcesTableClients": "Klientami", "resourcesTableAndOnlyAccessibleInternally": "i są dostępne tylko wewnętrznie po połączeniu z klientem.", "resourcesTableNoTargets": "Brak celów", "resourcesTableHealthy": "Zdrowe", "resourcesTableDegraded": "Degradacja", "resourcesTableOffline": "Offline", "resourcesTableUnknown": "Nieznane", "resourcesTableNotMonitored": "Nie monitorowano", "editInternalResourceDialogEditClientResource": "Edytuj Zasoby Prywatne", "editInternalResourceDialogUpdateResourceProperties": "Aktualizuj konfigurację zasobów i kontrolę dostępu dla {resourceName}", "editInternalResourceDialogResourceProperties": "Właściwości zasobów", "editInternalResourceDialogName": "Nazwa", "editInternalResourceDialogProtocol": "Protokół", "editInternalResourceDialogSitePort": "Port witryny", "editInternalResourceDialogTargetConfiguration": "Konfiguracja celu", "editInternalResourceDialogCancel": "Anuluj", "editInternalResourceDialogSaveResource": "Zapisz zasób", "editInternalResourceDialogSuccess": "Sukces", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Wewnętrzny zasób zaktualizowany pomyślnie", "editInternalResourceDialogError": "Błąd", "editInternalResourceDialogFailedToUpdateInternalResource": "Nie udało się zaktualizować wewnętrznego zasobu", "editInternalResourceDialogNameRequired": "Nazwa jest wymagana", "editInternalResourceDialogNameMaxLength": "Nazwa nie może mieć więcej niż 255 znaków", "editInternalResourceDialogProxyPortMin": "Port proxy musi wynosić przynajmniej 1", "editInternalResourceDialogProxyPortMax": "Port proxy nie może być większy niż 65536", "editInternalResourceDialogInvalidIPAddressFormat": "Nieprawidłowy format adresu IP", "editInternalResourceDialogDestinationPortMin": "Port docelowy musi wynosić przynajmniej 1", "editInternalResourceDialogDestinationPortMax": "Port docelowy nie może być większy niż 65536", "editInternalResourceDialogPortModeRequired": "Protokół, port proxy i port docelowy są wymagane dla trybu portu", "editInternalResourceDialogMode": "Tryb", "editInternalResourceDialogModePort": "Port", "editInternalResourceDialogModeHost": "Host", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Miejsce docelowe", "editInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.", "editInternalResourceDialogDestinationIPDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.", "editInternalResourceDialogDestinationCidrDescription": "Zakres CIDR zasobu w sieci witryny.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Opcjonalny wewnętrzny alias DNS dla tego zasobu.", "createInternalResourceDialogNoSitesAvailable": "Brak dostępnych stron", "createInternalResourceDialogNoSitesAvailableDescription": "Musisz mieć co najmniej jedną stronę Newt z skonfigurowanym podsiecią, aby tworzyć wewnętrzne zasoby.", "createInternalResourceDialogClose": "Zamknij", "createInternalResourceDialogCreateClientResource": "Utwórz zasób prywatny", "createInternalResourceDialogCreateClientResourceDescription": "Utwórz nowy zasób, który będzie dostępny tylko dla klientów podłączonych do organizacji", "createInternalResourceDialogResourceProperties": "Właściwości zasobów", "createInternalResourceDialogName": "Nazwa", "createInternalResourceDialogSite": "Witryna", "selectSite": "Wybierz stronę...", "noSitesFound": "Nie znaleziono stron.", "createInternalResourceDialogProtocol": "Protokół", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Port witryny", "createInternalResourceDialogSitePortDescription": "Użyj tego portu, aby uzyskać dostęp do zasobu na stronie, gdy połączony z klientem.", "createInternalResourceDialogTargetConfiguration": "Konfiguracja celu", "createInternalResourceDialogDestinationIPDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.", "createInternalResourceDialogDestinationPortDescription": "Port na docelowym IP, gdzie zasób jest dostępny.", "createInternalResourceDialogCancel": "Anuluj", "createInternalResourceDialogCreateResource": "Utwórz zasób", "createInternalResourceDialogSuccess": "Sukces", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Wewnętrzny zasób utworzony pomyślnie", "createInternalResourceDialogError": "Błąd", "createInternalResourceDialogFailedToCreateInternalResource": "Nie udało się utworzyć wewnętrznego zasobu", "createInternalResourceDialogNameRequired": "Nazwa jest wymagana", "createInternalResourceDialogNameMaxLength": "Nazwa nie może mieć więcej niż 255 znaków", "createInternalResourceDialogPleaseSelectSite": "Proszę wybrać stronę", "createInternalResourceDialogProxyPortMin": "Port proxy musi wynosić przynajmniej 1", "createInternalResourceDialogProxyPortMax": "Port proxy nie może być większy niż 65536", "createInternalResourceDialogInvalidIPAddressFormat": "Nieprawidłowy format adresu IP", "createInternalResourceDialogDestinationPortMin": "Port docelowy musi wynosić przynajmniej 1", "createInternalResourceDialogDestinationPortMax": "Port docelowy nie może być większy niż 65536", "createInternalResourceDialogPortModeRequired": "Protokół, port proxy i port docelowy są wymagane dla trybu portu", "createInternalResourceDialogMode": "Tryb", "createInternalResourceDialogModePort": "Port", "createInternalResourceDialogModeHost": "Host", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Miejsce docelowe", "createInternalResourceDialogDestinationHostDescription": "Adres IP lub nazwa hosta zasobu w sieci witryny.", "createInternalResourceDialogDestinationCidrDescription": "Zakres CIDR zasobu w sieci witryny.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Opcjonalny wewnętrzny alias DNS dla tego zasobu.", "siteConfiguration": "Konfiguracja", "siteAcceptClientConnections": "Akceptuj połączenia klienta", "siteAcceptClientConnectionsDescription": "Zezwalaj urządzeniom i klientom na dostęp do zasobów na tej stronie. Może to zostać zmienione później.", "siteAddress": "Adres witryny (Zaawansowany)", "siteAddressDescription": "Adres wewnętrzny witryny. Musi mieścić się w podsieci organizacji.", "siteNameDescription": "Wyświetlana nazwa witryny, która może zostać zmieniona później.", "autoLoginExternalIdp": "Automatyczny login z zewnętrznym IDP", "autoLoginExternalIdpDescription": "Natychmiastowe przekierowanie użytkownika do zewnętrznego IDP w celu uwierzytelnienia.", "selectIdp": "Wybierz IDP", "selectIdpPlaceholder": "Wybierz IDP...", "selectIdpRequired": "Proszę wybrać IDP, gdy aktywne jest automatyczne logowanie.", "autoLoginTitle": "Przekierowywanie", "autoLoginDescription": "Przekierowanie do zewnętrznego dostawcy tożsamości w celu uwierzytelnienia.", "autoLoginProcessing": "Przygotowywanie uwierzytelniania...", "autoLoginRedirecting": "Przekierowanie do logowania...", "autoLoginError": "Błąd automatycznego logowania", "autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.", "autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania.", "remoteExitNodeManageRemoteExitNodes": "Zdalne węzły", "remoteExitNodeDescription": "Hosting własnych zdalnych węzłów przekaźnikowych i serwerów proxy", "remoteExitNodes": "Węzły", "searchRemoteExitNodes": "Szukaj węzłów...", "remoteExitNodeAdd": "Dodaj węzeł", "remoteExitNodeErrorDelete": "Błąd podczas usuwania węzła", "remoteExitNodeQuestionRemove": "Czy na pewno chcesz usunąć węzeł z organizacji?", "remoteExitNodeMessageRemove": "Po usunięciu, węzeł nie będzie już dostępny.", "remoteExitNodeConfirmDelete": "Potwierdź usunięcie węzła", "remoteExitNodeDelete": "Usuń węzeł", "sidebarRemoteExitNodes": "Zdalne węzły", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Sekret", "remoteExitNodeCreate": { "title": "Utwórz zdalny węzeł", "description": "Utwórz nowy, samodzielnie hostowany węzeł przekaźnika zdalnego i serwera proxy", "viewAllButton": "Zobacz wszystkie węzły", "strategy": { "title": "Strategia Tworzenia", "description": "Wybierz sposób, w jaki chcesz utworzyć zdalny węzeł", "adopt": { "title": "Zaadoptuj Węzeł", "description": "Wybierz to, jeśli masz już dane logowania dla węzła." }, "generate": { "title": "Generuj Klucze", "description": "Wybierz to, jeśli chcesz wygenerować nowe klucze dla węzła." } }, "adopt": { "title": "Zaadoptuj Istniejący Węzeł", "description": "Wprowadź dane logowania istniejącego węzła, który chcesz przyjąć", "nodeIdLabel": "ID węzła", "nodeIdDescription": "ID istniejącego węzła, który chcesz przyjąć", "secretLabel": "Sekret", "secretDescription": "Sekretny klucz istniejącego węzła", "submitButton": "Przyjmij węzeł" }, "generate": { "title": "Wygenerowane Poświadczenia", "description": "Użyj tych danych logowania, aby skonfigurować węzeł", "nodeIdTitle": "ID węzła", "secretTitle": "Sekret", "saveCredentialsTitle": "Dodaj Poświadczenia do Konfiguracji", "saveCredentialsDescription": "Dodaj te poświadczenia do pliku konfiguracyjnego swojego samodzielnie-hostowanego węzła Pangolin, aby zakończyć połączenie.", "submitButton": "Utwórz węzeł" }, "validation": { "adoptRequired": "Identyfikator węzła i sekret są wymagane podczas przyjmowania istniejącego węzła" }, "errors": { "loadDefaultsFailed": "Nie udało się załadować domyślnych ustawień", "defaultsNotLoaded": "Domyślne ustawienia nie zostały załadowane", "createFailed": "Nie udało się utworzyć węzła" }, "success": { "created": "Węzeł utworzony pomyślnie" } }, "remoteExitNodeSelection": "Wybór węzła", "remoteExitNodeSelectionDescription": "Wybierz węzeł do przekierowania ruchu dla tej lokalnej witryny", "remoteExitNodeRequired": "Węzeł musi być wybrany dla lokalnych witryn", "noRemoteExitNodesAvailable": "Brak dostępnych węzłów", "noRemoteExitNodesAvailableDescription": "Węzły nie są dostępne dla tej organizacji. Utwórz węzeł, aby używać lokalnych witryn.", "exitNode": "Węzeł Wyjściowy", "country": "Kraj", "rulesMatchCountry": "Obecnie bazuje na adresie IP źródła", "managedSelfHosted": { "title": "Zarządzane Samodzielnie-Hostingowane", "description": "Większa niezawodność i niska konserwacja serwera Pangolin z dodatkowymi dzwonkami i sygnałami", "introTitle": "Zarządzany samowystarczalny Pangolin", "introDescription": "jest opcją wdrażania zaprojektowaną dla osób, które chcą prostoty i dodatkowej niezawodności, przy jednoczesnym utrzymaniu swoich danych prywatnych i samodzielnych.", "introDetail": "Z tą opcją nadal obsługujesz swój własny węzeł Pangolin — tunele, zakończenie SSL i ruch na Twoim serwerze. Różnica polega na tym, że zarządzanie i monitorowanie odbywa się za pomocą naszej tablicy rozdzielczej, która odblokowuje szereg korzyści:", "benefitSimplerOperations": { "title": "Uproszczone operacje", "description": "Nie ma potrzeby uruchamiania własnego serwera pocztowego lub ustawiania skomplikowanych powiadomień. Będziesz mieć kontrolę zdrowia i powiadomienia o przestoju." }, "benefitAutomaticUpdates": { "title": "Automatyczne aktualizacje", "description": "Panel chmury rozwija się szybko, więc otrzymujesz nowe funkcje i poprawki błędów bez konieczności ręcznego ciągnięcia nowych kontenerów za każdym razem." }, "benefitLessMaintenance": { "title": "Mniej konserwacji", "description": "Brak migracji bazy danych, kopii zapasowych lub dodatkowej infrastruktury do zarządzania. Obsługujemy to w chmurze." }, "benefitCloudFailover": { "title": "Przegrywanie w chmurze", "description": "Jeśli Twój węzeł zostanie wyłączony, tunele mogą tymczasowo zawieść do naszych punktów w chmurze, dopóki nie przyniesiesz go z powrotem do trybu online." }, "benefitHighAvailability": { "title": "Wysoka dostępność (PoPs)", "description": "Możesz również dołączyć wiele węzłów do swojego konta w celu nadmiarowości i lepszej wydajności." }, "benefitFutureEnhancements": { "title": "Przyszłe ulepszenia", "description": "Planujemy dodać więcej narzędzi analitycznych, ostrzegawczych i zarządzania, aby zwiększyć odporność wdrożenia." }, "docsAlert": { "text": "Dowiedz się więcej o opcji zarządzania samodzielnym hostingiem w naszym", "documentation": "dokumentacja" }, "convertButton": "Konwertuj ten węzeł do zarządzanego samodzielnie" }, "internationaldomaindetected": "Wykryto międzynarodową domenę", "willbestoredas": "Będą przechowywane jako:", "roleMappingDescription": "Określ jak role są przypisywane do użytkowników podczas logowania się, gdy automatyczne świadczenie jest włączone.", "selectRole": "Wybierz rolę", "roleMappingExpression": "Wyrażenie", "selectRolePlaceholder": "Wybierz rolę", "selectRoleDescription": "Wybierz rolę do przypisania wszystkim użytkownikom od tego dostawcy tożsamości", "roleMappingExpressionDescription": "Wprowadź wyrażenie JMESŚcieżki, aby wyodrębnić informacje o roli z tokenu ID", "idpTenantIdRequired": "ID lokatora jest wymagane", "invalidValue": "Nieprawidłowa wartość", "idpTypeLabel": "Typ dostawcy tożsamości", "roleMappingExpressionPlaceholder": "np. zawiera(grupy, 'admin') && 'Admin' || 'Członek'", "idpGoogleConfiguration": "Konfiguracja Google", "idpGoogleConfigurationDescription": "Skonfiguruj dane logowania Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Klucz tajny klienta Google OAuth2", "idpAzureConfiguration": "Konfiguracja Azure Entra ID", "idpAzureConfigurationDescription": "Skonfiguruj poświadczenia Aure Entra ID OAuth2", "idpTenantId": "ID Najemcy", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "Identyfikator dzierżawcy azure (znaleziony w Azuure Active Directory podglądu)", "idpAzureClientIdDescription": "Identyfikator klienta aplikacji Azure", "idpAzureClientSecretDescription": "Klucz tajny klienta aplikacji Azure", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Konfiguracja Google", "idpAzureConfigurationTitle": "Konfiguracja Azure Entra ID", "idpTenantIdLabel": "ID Najemcy", "idpAzureClientIdDescription2": "Identyfikator klienta aplikacji Azure", "idpAzureClientSecretDescription2": "Klucz tajny klienta aplikacji Azure", "idpGoogleDescription": "Dostawca Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Podsieć", "subnetDescription": "Podsieć dla konfiguracji sieci tej organizacji.", "customDomain": "Niestandardowa domena", "authPage": "Strony uwierzytelniania", "authPageDescription": "Ustaw niestandardową domenę dla stron uwierzytelniania organizacji", "authPageDomain": "Domena strony uwierzytelniania", "authPageBranding": "Niestandardowy branding", "authPageBrandingDescription": "Konfiguruj branding, który pojawia się na stronach uwierzytelniania dla tej organizacji", "authPageBrandingUpdated": "Branding strony uwierzytelniania został pomyślnie zaktualizowany", "authPageBrandingRemoved": "Marka strony uwierzytelniania została pomyślnie usunięta", "authPageBrandingRemoveTitle": "Usuń markę strony uwierzytelniania", "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)", "brandingOrgTitle": "Tytuł dla strony uwierzytelniania organizacji", "brandingOrgDescription": "{orgName} zostanie zastąpione nazwą organizacji", "brandingOrgSubtitle": "Podtytuł dla strony uwierzytelniania organizacji", "brandingResourceTitle": "Tytuł dla strony uwierzytelniania zasobu", "brandingResourceSubtitle": "Podtytuł dla strony uwierzytelniania zasobu", "brandingResourceDescription": "{resourceName} zostanie zastąpione nazwą organizacji", "saveAuthPageDomain": "Zapisz domenę", "saveAuthPageBranding": "Zapisz branding", "removeAuthPageBranding": "Usuń branding", "noDomainSet": "Nie ustawiono domeny", "changeDomain": "Zmień domenę", "selectDomain": "Wybierz domenę", "restartCertificate": "Uruchom ponownie certyfikat", "editAuthPageDomain": "Edytuj domenę strony uwierzytelniania", "setAuthPageDomain": "Ustaw domenę strony uwierzytelniania", "failedToFetchCertificate": "Nie udało się pobrać certyfikatu", "failedToRestartCertificate": "Nie udało się ponownie uruchomić certyfikatu", "addDomainToEnableCustomAuthPages": "Użytkownicy będą mogli uzyskać dostęp do strony logowania organizacji i zakończyć uwierzytelnianie zasobów za pomocą tej domeny.", "selectDomainForOrgAuthPage": "Wybierz domenę dla strony uwierzytelniania organizacji", "domainPickerProvidedDomain": "Dostarczona domena", "domainPickerFreeProvidedDomain": "Darmowa oferowana domena", "domainPickerVerified": "Zweryfikowano", "domainPickerUnverified": "Niezweryfikowane", "domainPickerInvalidSubdomainStructure": "Ta subdomena zawiera nieprawidłowe znaki lub strukturę. Zostanie ona automatycznie oczyszczona po zapisaniu.", "domainPickerError": "Błąd", "domainPickerErrorLoadDomains": "Nie udało się załadować domen organizacji", "domainPickerErrorCheckAvailability": "Nie udało się sprawdzić dostępności domeny", "domainPickerInvalidSubdomain": "Nieprawidłowa subdomena", "domainPickerInvalidSubdomainRemoved": "Wejście \"{sub}\" zostało usunięte, ponieważ jest nieprawidłowe.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" nie może być poprawne dla {domain}.", "domainPickerSubdomainSanitized": "Poddomena oczyszczona", "domainPickerSubdomainCorrected": "\"{sub}\" został skorygowany do \"{sanitized}\"", "orgAuthSignInTitle": "Logowanie do organizacji", "orgAuthChooseIdpDescription": "Wybierz swojego dostawcę tożsamości, aby kontynuować", "orgAuthNoIdpConfigured": "Ta organizacja nie ma skonfigurowanych żadnych dostawców tożsamości. Zamiast tego możesz zalogować się za pomocą swojej tożsamości Pangolin.", "orgAuthSignInWithPangolin": "Zaloguj się używając Pangolin", "orgAuthSignInToOrg": "Zaloguj się do organizacji", "orgAuthSelectOrgTitle": "Logowanie do organizacji", "orgAuthSelectOrgDescription": "Wprowadź identyfikator organizacji, aby kontynuować", "orgAuthOrgIdPlaceholder": "twoja-organizacja", "orgAuthOrgIdHelp": "Wpisz unikalny identyfikator swojej organizacji", "orgAuthSelectOrgHelp": "Po wpisaniu ID organizacji zostaniesz przeniesiony na stronę logowania organizacji, gdzie możesz użyć SSO lub danych logowania organizacji.", "orgAuthRememberOrgId": "Zapamiętaj ten identyfikator organizacji", "orgAuthBackToSignIn": "Powrót do standardowego logowania", "orgAuthNoAccount": "Nie masz konta?", "subscriptionRequiredToUse": "Do korzystania z tej funkcji wymagana jest subskrypcja.", "mustUpgradeToUse": "Musisz uaktualnić subskrypcję, aby korzystać z tej funkcji.", "subscriptionRequiredTierToUse": "Ta funkcja wymaga funkcji {tier} lub wyższej.", "upgradeToTierToUse": "Aby skorzystać z tej funkcji, przejdź na {tier} lub wyższy pakiet.", "subscriptionTierTier1": "Strona główna", "subscriptionTierTier2": "Drużyna", "subscriptionTierTier3": "Biznes", "subscriptionTierEnterprise": "Przedsiębiorstwo", "idpDisabled": "Dostawcy tożsamości są wyłączeni", "orgAuthPageDisabled": "Strona autoryzacji organizacji jest wyłączona.", "domainRestartedDescription": "Weryfikacja domeny zrestartowana pomyślnie", "resourceAddEntrypointsEditFile": "Edytuj plik: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Edytuj plik: docker-compose.yml", "emailVerificationRequired": "Weryfikacja adresu e-mail jest wymagana. Zaloguj się ponownie przez {dashboardUrl}/auth/login zakończył ten krok. Następnie wróć tutaj.", "twoFactorSetupRequired": "Konfiguracja uwierzytelniania dwuskładnikowego jest wymagana. Zaloguj się ponownie przez {dashboardUrl}/auth/login dokończ ten krok. Następnie wróć tutaj.", "additionalSecurityRequired": "Wymagane dodatkowe zabezpieczenie", "organizationRequiresAdditionalSteps": "Ta organizacja wymaga dodatkowych kroków bezpieczeństwa, zanim będziesz mógł uzyskać dostęp do zasobów.", "completeTheseSteps": "Wykonaj te kroki", "enableTwoFactorAuthentication": "Włącz uwierzytelnianie dwuskładnikowe", "completeSecuritySteps": "Zakończ kroki bezpieczeństwa", "securitySettings": "Ustawienia zabezpieczeń", "dangerSection": "Strefa zagrożenia", "dangerSectionDescription": "Trwale usuń wszystkie dane związane z tą organizacją", "securitySettingsDescription": "Skonfiguruj polityki bezpieczeństwa dla organizacji", "requireTwoFactorForAllUsers": "Wymagaj uwierzytelniania dwuetapowego dla wszystkich użytkowników", "requireTwoFactorDescription": "Po włączeniu wszyscy użytkownicy wewnętrzni w tej organizacji muszą mieć włączone uwierzytelnianie dwuskładnikowe, aby uzyskać dostęp do organizacji.", "requireTwoFactorDisabledDescription": "Ta funkcja wymaga poprawnej licencji (Enterprise) lub aktywnej subskrypcji (SaaaS)", "requireTwoFactorCannotEnableDescription": "Musisz włączyć uwierzytelnianie dwuskładnikowe dla swojego konta przed wymuszaniem go dla wszystkich użytkowników", "maxSessionLength": "Maksymalna długość sesji", "maxSessionLengthDescription": "Ustaw maksymalny czas trwania sesji użytkownika. Po tym czasie użytkownicy będą musieli ponownie uwierzytelniać.", "maxSessionLengthDisabledDescription": "Ta funkcja wymaga poprawnej licencji (Enterprise) lub aktywnej subskrypcji (SaaaS)", "selectSessionLength": "Wybierz długość sesji", "unenforced": "Niewymuszony", "1Hour": "1 godzina", "3Hours": "3 godziny", "6Hours": "6 godzin", "12Hours": "12 godzin", "1DaySession": "1 dzień", "3Days": "3 dni", "7Days": "7 dni", "14Days": "14 dni", "30DaysSession": "30 dni", "90DaysSession": "90 dni", "180DaysSession": "180 dni", "passwordExpiryDays": "Hasło wygasa", "editPasswordExpiryDescription": "Ustaw liczbę dni zanim użytkownicy będą musieli zmienić swoje hasło.", "selectPasswordExpiry": "Wybierz wygasanie hasła", "30Days": "30 dni", "1Day": "1 dzień", "60Days": "60 dni", "90Days": "90 dni", "180Days": "180 dni", "1Year": "1 rok", "subscriptionBadge": "Wymagana subskrypcja", "securityPolicyChangeWarning": "Ostrzeżenie o zmianach w polityce bezpieczeństwa", "securityPolicyChangeDescription": "Zamierzasz zmienić ustawienia polityki bezpieczeństwa. Po zapisaniu konieczne może być ponowne uwierzytelnienie w celu zapewnienia zgodności z tymi aktualizacjami polityki. Wszyscy użytkownicy, którzy nie są zgodni, będą również musieli ponownie uwierzytelniać.", "securityPolicyChangeConfirmMessage": "Potwierdzam", "securityPolicyChangeWarningText": "To wpłynie na wszystkich użytkowników w organizacji", "authPageErrorUpdateMessage": "Wystąpił błąd podczas aktualizacji ustawień strony uwierzytelniania", "authPageErrorUpdate": "Nie można zaktualizować strony uwierzytelniania", "authPageDomainUpdated": "Domena strony uwierzytelniania została pomyślnie zaktualizowana", "healthCheckNotAvailable": "Lokalny", "rewritePath": "Przepis Ścieżki", "rewritePathDescription": "Opcjonalnie przepisz ścieżkę przed przesłaniem do celu.", "continueToApplication": "Kontynuuj do aplikacji", "checkingInvite": "Sprawdzanie zaproszenia", "setResourceHeaderAuth": "setResourceHeaderAuth", "resourceHeaderAuthRemove": "Usuń autoryzację nagłówka", "resourceHeaderAuthRemoveDescription": "Uwierzytelnianie nagłówka zostało pomyślnie usunięte.", "resourceErrorHeaderAuthRemove": "Nie udało się usunąć uwierzytelniania nagłówka", "resourceErrorHeaderAuthRemoveDescription": "Nie można usunąć uwierzytelniania nagłówka zasobu.", "resourceHeaderAuthProtectionEnabled": "Uwierzytelnianie nagłówka włączone", "resourceHeaderAuthProtectionDisabled": "Uwierzytelnianie nagłówka wyłączone", "headerAuthRemove": "Usuń autoryzację nagłówka", "headerAuthAdd": "Dodaj Autoryzacja nagłówka", "resourceErrorHeaderAuthSetup": "Nie udało się ustawić uwierzytelniania nagłówka", "resourceErrorHeaderAuthSetupDescription": "Nie można ustawić uwierzytelniania nagłówka dla zasobu.", "resourceHeaderAuthSetup": "Uwierzytelnianie nagłówka ustawione pomyślnie", "resourceHeaderAuthSetupDescription": "Uwierzytelnianie nagłówka zostało ustawione.", "resourceHeaderAuthSetupTitle": "Ustaw uwierzytelnianie nagłówka", "resourceHeaderAuthSetupTitleDescription": "Ustaw podstawowe dane uwierzytelniające (nazwa użytkownika i hasło), aby chronić ten zasób za pomocą uwierzytelniania nagłówka HTTP. Uzyskaj dostęp za pomocą formatu https://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Ustaw uwierzytelnianie nagłówka", "actionSetResourceHeaderAuth": "Ustaw uwierzytelnianie nagłówka", "enterpriseEdition": "Edycja Enterprise", "unlicensed": "Nielicencjonowane", "beta": "Beta", "manageUserDevices": "Urządzenia użytkownika", "manageUserDevicesDescription": "Przeglądaj i zarządzaj urządzeniami, które użytkownicy używają do prywatnego łączenia się z zasobami", "downloadClientBannerTitle": "Pobierz klienta Pangolin", "downloadClientBannerDescription": "Pobierz klienta Pangolin dla swojego systemu, aby połączyć się z siecią Pangolin i uzyskać dostęp do zasobów prywatnie.", "manageMachineClients": "Zarządzaj klientami maszyn", "manageMachineClientsDescription": "Tworzenie i zarządzanie klientami, których serwery i systemy używają do prywatnego łączenia się z zasobami", "machineClientsBannerTitle": "Serwery i systemy zautomatyzowane", "machineClientsBannerDescription": "Klienci maszyn służą dla serwerów i systemów zautomatyzowanych, które nie są powiązane z konkretnym użytkownikiem. Uwierzytelniają się za pomocą identyfikatora i sekretu i mogą działać z Pangolin CLI, Olm CLI lub Olm jako kontener.", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Kontener Olm", "clientsTableUserClients": "Użytkownik", "clientsTableMachineClients": "Maszyna", "licenseTableValidUntil": "Ważny do", "saasLicenseKeysSettingsTitle": "Licencje przedsiębiorstwa", "saasLicenseKeysSettingsDescription": "Generuj i zarządzaj kluczami licencyjnymi Enterprise dla samodzielnych instancji Pangolin", "sidebarEnterpriseLicenses": "Licencje", "generateLicenseKey": "Generuj klucz licencyjny", "generateLicenseKeyForm": { "validation": { "emailRequired": "Wprowadź poprawny adres e-mail", "useCaseTypeRequired": "Proszę wybrać typ litery", "firstNameRequired": "Imię jest wymagane", "lastNameRequired": "Nazwisko jest wymagane", "primaryUseRequired": "Opisz swoje podstawowe użycie", "jobTitleRequiredBusiness": "Tytuł pracy jest wymagany do użytku służbowego", "industryRequiredBusiness": "Przemysł jest wymagany do celów biznesowych.", "stateProvinceRegionRequired": "Wymagany jest stan/województwo/region", "postalZipCodeRequired": "Kod pocztowy jest wymagany", "companyNameRequiredBusiness": "Nazwa firmy jest wymagana do użytku służbowego", "countryOfResidenceRequiredBusiness": "Kraj zamieszkania jest wymagany do celów służbowych", "countryRequiredPersonal": "Kraj jest wymagany do użytku osobistego", "agreeToTermsRequired": "Musisz zaakceptować regulamin", "complianceConfirmationRequired": "Musisz potwierdzić zgodność z Fossorial Commercial License" }, "useCaseOptions": { "personal": { "title": "Użytkowanie osobiste", "description": "Dla celów indywidualnych, niekomercyjnych, takich jak nauka, projekty osobiste lub eksperymenty." }, "business": { "title": "Wykorzystanie służbowe", "description": "Do użytku w ramach organizacji, przedsiębiorstw lub działalności komercyjnej lub generującej dochody." } }, "steps": { "emailLicenseType": { "title": "Typ adresu e-mail i licencji", "description": "Wprowadź swój adres e-mail i wybierz rodzaj licencji" }, "personalInformation": { "title": "Informacje osobiste", "description": "Powiedz nam o sobie" }, "contactInformation": { "title": "Informacje kontaktowe", "description": "Twoje dane kontaktowe" }, "termsGenerate": { "title": "Reguły i generuj", "description": "Przejrzyj i zaakceptuj warunki generowania licencji" } }, "alerts": { "commercialUseDisclosure": { "title": "Ujawnienie użycia", "description": "Wybierz poziom licencji, który dokładnie odzwierciedla zamierzone użycie. Licencja osobista pozwala na bezpłatne wykorzystanie oprogramowania do działalności komercyjnej, o charakterze indywidualnym, niekomercyjnym lub na małą skalę, o rocznym dochodzie brutto poniżej 100 000 USD. Wszelkie zastosowania wykraczające poza te ograniczenia – w tym wykorzystanie w przedsiębiorstwie, organizacja, lub inne środowisko generujące dochód – wymaga ważnej licencji przedsiębiorstwa i uiszczenia stosownej opłaty licencyjnej. Wszyscy użytkownicy, niezależnie od tego, czy są prywatni czy przedsiębiorcy, muszą przestrzegać warunków licencji Fossorial Commercial License." }, "trialPeriodInformation": { "title": "Informacje o okresie próbnym", "description": "Ten klucz licencyjny umożliwia przedsiębiorstwom funkcje na 7-dniowy okres oceny. Ciągły dostęp do płatnych funkcji po zakończeniu okresu oceny wymaga aktywacji na podstawie ważnej licencji osobistej lub prywatnej. W celu uzyskania licencji przedsiębiorstwa skontaktuj się z sales@pangolin.net." } }, "form": { "useCaseQuestion": "Używasz Pangolin do użytku osobistego lub biznesowego?", "firstName": "Imię", "lastName": "Nazwisko", "jobTitle": "Tytuł zadania", "primaryUseQuestion": "Na co planujesz przede wszystkim stosować lek Pangolin?", "industryQuestion": "Jaki jest twój przemysł?", "prospectiveUsersQuestion": "Ilu potencjalnych użytkowników oczekujesz?", "prospectiveSitesQuestion": "Ile potencjalnych stron (tuneli) oczekujesz?", "companyName": "Nazwa firmy", "countryOfResidence": "Kraj zamieszkania", "stateProvinceRegion": "Województwo / Region", "postalZipCode": "Kod pocztowy", "companyWebsite": "Strona internetowa firmy", "companyPhoneNumber": "Numer telefonu firmy", "country": "Kraj", "phoneNumberOptional": "Numer telefonu (opcjonalnie)", "complianceConfirmation": "Potwierdzam, że podane przeze mnie informacje są dokładne i że jestem zgodny z Fossorial Commercial License. Zgłaszanie nieprawidłowych informacji lub błędne oznaczanie użycia produktu jest naruszeniem licencji i może skutkować cofnięciem klucza." }, "buttons": { "close": "Zamknij", "previous": "Poprzedni", "next": "Następny", "generateLicenseKey": "Generuj klucz licencyjny" }, "toasts": { "success": { "title": "Klucz licencyjny wygenerowany pomyślnie", "description": "Twój klucz licencyjny został wygenerowany i jest gotowy do użycia." }, "error": { "title": "Nie udało się wygenerować klucza licencyjnego", "description": "Wystąpił błąd podczas generowania klucza licencji." } } }, "newPricingLicenseForm": { "title": "Uzyskaj licencję", "description": "Wybierz plan i powiedz nam, jak planujesz korzystać z Pangolin.", "chooseTier": "Wybierz swój plan", "viewPricingLink": "Zobacz cenniki, funkcje i limity", "tiers": { "starter": { "title": "Rozpocznij", "description": "Środki te przeznaczone są na pokrycie wydatków na personel i wydatków administracyjnych Agencji (tytuły 1 i 2) oraz jej wydatków operacyjnych (tytuł 3)." }, "scale": { "title": "Skala", "description": "Cechy przedsiębiorstw, 50 użytkowników, 50 obiektów i wsparcie priorytetowe." } }, "personalUseOnly": "Wyłącznie do użytku osobistego (bezpłatna licencja – brak zamówień)", "buttons": { "continueToCheckout": "Przejdź do zamówienia" }, "toasts": { "checkoutError": { "title": "Błąd zamówienia", "description": "Nie można uruchomić zamówienia. Spróbuj ponownie." } } }, "priority": "Priorytet", "priorityDescription": "Najpierw oceniane są trasy priorytetowe. Priorytet = 100 oznacza automatyczne zamawianie (decyzje systemowe). Użyj innego numeru, aby wyegzekwować ręczny priorytet.", "instanceName": "Nazwa instancji", "pathMatchModalTitle": "Skonfiguruj dopasowanie ścieżki", "pathMatchModalDescription": "Skonfiguruj sposób dopasowania przychodzących żądań na podstawie ich ścieżki.", "pathMatchType": "Typ dopasowania", "pathMatchPrefix": "Prefiks", "pathMatchExact": "Dokładny", "pathMatchRegex": "Regex", "pathMatchValue": "Wartość ścieżki", "clear": "Wyczyść", "saveChanges": "Zapisz zmiany", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/ścieżka", "pathMatchPrefixHelp": "Przykład: /api pasuje do /api, /api/users itp.", "pathMatchExactHelp": "Przykład: /api pasuje tylko /api", "pathMatchRegexHelp": "Przykład: ^/api/.* pasuje do /api/cokolwiek", "pathRewriteModalTitle": "Konfiguruj Przepisywanie Ścieżki", "pathRewriteModalDescription": "Przekształć dopasowaną ścieżkę przed przekierowaniem do celu.", "pathRewriteType": "Typ przekierowania", "pathRewritePrefixOption": "Prefiks - Zamień prefiks", "pathRewriteExactOption": "Dokładny - Zamień całą ścieżkę", "pathRewriteRegexOption": "Regex - zamiennik wzoru", "pathRewriteStripPrefixOption": "Prefiks paska - Usuń prefiks", "pathRewriteValue": "Przepisz wartość", "pathRewriteRegexPlaceholder": "/nowy/$1", "pathRewriteDefaultPlaceholder": "/new-path", "pathRewritePrefixHelp": "Zastąp dopasowany prefiks tą wartością", "pathRewriteExactHelp": "Zastąp całą ścieżkę tą wartością, gdy ścieżka dokładnie pasuje do siebie", "pathRewriteRegexHelp": "Użyj grup przechwytywania takich jak $1, $2 do zamiany", "pathRewriteStripPrefixHelp": "Pozostaw puste, aby usunąć prefiks lub podać nowy prefiks", "pathRewritePrefix": "Prefiks", "pathRewriteExact": "Dokładny", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Pasek", "pathRewriteStripLabel": "pasek", "sidebarEnableEnterpriseLicense": "Włącz licencję przedsiębiorstwa", "cannotbeUndone": "Tej operacji nie można cofnąć.", "toConfirm": "do potwierdzenia.", "deleteClientQuestion": "Czy na pewno chcesz usunąć klienta z witryny i organizacji?", "clientMessageRemove": "Po usunięciu, klient nie będzie już mógł połączyć się z witryną.", "sidebarLogs": "Logi", "request": "Żądanie", "requests": "Żądania", "logs": "Logi", "logsSettingsDescription": "Monitorowanie logów zbieranych z tej organizacji", "searchLogs": "Szukaj dzienników...", "action": "Akcja", "actor": "Aktor", "timestamp": "Znacznik czasu", "accessLogs": "Logi dostępu", "exportCsv": "Eksportuj CSV", "exportError": "Nieznany błąd podczas eksportowania CSV", "exportCsvTooltip": "W obrębie zakresu czasowego", "actorId": "Identyfikator podmiotu", "allowedByRule": "Dozwolone przez regułę", "allowedNoAuth": "Dozwolone Brak Auth", "validAccessToken": "Ważny token dostępu", "validHeaderAuth": "Valid header auth", "validPincode": "Valid Pincode", "validPassword": "Prawidłowe hasło", "validEmail": "Valid email", "validSSO": "Valid SSO", "resourceBlocked": "Zasób zablokowany", "droppedByRule": "Upuszczone przez regułę", "noSessions": "Brak sesji", "temporaryRequestToken": "Tymczasowy token żądania", "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Powód", "requestLogs": "Dzienniki żądań", "requestAnalytics": "Żądanie Analityki", "host": "Host", "location": "Lokalizacja", "actionLogs": "Dzienniki działań", "sidebarLogsRequest": "Dzienniki żądań", "sidebarLogsAccess": "Logi dostępu", "sidebarLogsAction": "Dzienniki działań", "logRetention": "Zachowanie dziennika", "logRetentionDescription": "Zarządzaj jak długo różne typy logów są zachowane dla tej organizacji lub wyłącz je", "requestLogsDescription": "Zobacz szczegółowe dzienniki żądań zasobów w tej organizacji", "requestAnalyticsDescription": "Zobacz szczegółowe analizy żądań dla zasobów w tej organizacji", "logRetentionRequestLabel": "Zachowanie dziennika żądań", "logRetentionRequestDescription": "Jak długo zachować dzienniki żądań", "logRetentionAccessLabel": "Zachowanie dziennika dostępu", "logRetentionAccessDescription": "Jak długo zachować dzienniki dostępu", "logRetentionActionLabel": "Zachowanie dziennika akcji", "logRetentionActionDescription": "Jak długo zachować dzienniki akcji", "logRetentionDisabled": "Wyłączone", "logRetention3Days": "3 dni", "logRetention7Days": "7 dni", "logRetention14Days": "14 dni", "logRetention30Days": "30 dni", "logRetention90Days": "90 dni", "logRetentionForever": "Na zawsze", "logRetentionEndOfFollowingYear": "Koniec następnego roku", "actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji", "accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji", "licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja Enterprise Edition . Ta funkcja jest również dostępna w Pangolin Cloud.", "ossEnterpriseEditionRequired": "Enterprise Edition jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w Pangolin Cloud.", "certResolver": "Rozwiązywanie certyfikatów", "certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.", "selectCertResolver": "Wybierz Resolver certyfikatów", "enterCustomResolver": "Wprowadź niestandardowy Resolver", "preferWildcardCert": "Preferuj Certyfikat Wildcard", "unverified": "Niezweryfikowane", "domainSetting": "Ustawienia domeny", "domainSettingDescription": "Skonfiguruj ustawienia domeny", "preferWildcardCertDescription": "Spróbuj wygenerować certyfikat wieloznaczny (wymaga odpowiednio skonfigurowanego resolvera certyfikatu).", "recordName": "Nazwa rekordu", "auto": "Auto", "TTL": "TTL", "howToAddRecords": "Jak dodać rekordy", "dnsRecord": "Wpisy DNS", "required": "Wymagane", "domainSettingsUpdated": "Ustawienia domeny zaktualizowane pomyślnie", "orgOrDomainIdMissing": "Brakuje identyfikatora organizacji lub domeny", "loadingDNSRecords": "Ładowanie rekordów DNS...", "olmUpdateAvailableInfo": "Dostępna jest zaktualizowana wersja Olm. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze doświadczenia.", "client": "Klient", "proxyProtocol": "Ustawienia protokołu proxy", "proxyProtocolDescription": "Skonfiguruj protokół Proxy aby zachować adresy IP klienta dla usług TCP.", "enableProxyProtocol": "Włącz protokół proxy", "proxyProtocolInfo": "Zachowaj adresy IP klienta dla backendów TCP", "proxyProtocolVersion": "Wersja protokołu proxy", "version1": " Wersja 1 (zalecane)", "version2": "Wersja 2", "versionDescription": "Wersja 1 jest oparta na tekście i szeroko wspierana. Wersja 2 jest binarna i bardziej efektywna, ale mniej kompatybilna.", "warning": "Ostrzeżenie", "proxyProtocolWarning": "Aplikacja backend musi być skonfigurowana do akceptowania połączeń protokołu proxy. Jeśli Twój backend nie obsługuje protokołu Proxy, włączenie tego spowoduje przerwanie wszystkich połączeń, więc włącz to tylko jeśli wiesz, co robisz. Upewnij się, że konfiguracja twojego backendu do zaufanych nagłówków protokołu proxy z Traefik.", "restarting": "Restartowanie...", "manual": "Ręcznie", "messageSupport": "Obsługa wiadomości", "supportNotAvailableTitle": "Wsparcie niedostępne", "supportNotAvailableDescription": "Wsparcie nie jest teraz dostępne. Możesz wysłać e-mail na adres support@pangolin.net.", "supportRequestSentTitle": "Prośba o wsparcie wysłana", "supportRequestSentDescription": "Wiadomość została wysłana pomyślnie.", "supportRequestFailedTitle": "Nie udało się wysłać żądania", "supportRequestFailedDescription": "Wystąpił błąd podczas wysyłania prośby o wsparcie.", "supportSubjectRequired": "Temat jest wymagany", "supportSubjectMaxLength": "Temat musi mieć 255 znaków lub mniej", "supportMessageRequired": "Wiadomość jest wymagana", "supportReplyTo": "Odpowiedź do", "supportSubject": "Temat", "supportSubjectPlaceholder": "Wprowadź temat", "supportMessage": "Wiadomość", "supportMessagePlaceholder": "Wprowadź swoją wiadomość", "supportSending": "Wysyłanie...", "supportSend": "Wyślij", "supportMessageSent": "Wiadomość wysłana!", "supportWillContact": "Wkrótce będziemy w kontakcie!", "selectLogRetention": "Wybierz zatrzymanie dziennika", "terms": "Regulamin", "privacy": "Prywatność", "security": "Bezpieczeństwo", "docs": "Dokumentacja", "deviceActivation": "Aktywacja urządzenia", "deviceCodeInvalidFormat": "Kod musi mieć 9 znaków (np. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Nieprawidłowy lub wygasły kod", "deviceCodeVerifyFailed": "Nie udało się zweryfikować kodu urządzenia", "deviceCodeValidating": "Sprawdzanie kodu urządzenia...", "deviceCodeVerifying": "Weryfikowanie autoryzacji urządzenia...", "signedInAs": "Zalogowany jako", "deviceCodeEnterPrompt": "Wprowadź kod wyświetlany na urządzeniu", "continue": "Kontynuuj", "deviceUnknownLocation": "Nieznana lokalizacja", "deviceAuthorizationRequested": "Ta autoryzacja została zgłoszona do {location} na {date}. Upewnij się, że ufasz urządzeniu, ponieważ uzyska dostęp do konta.", "deviceLabel": "Urządzenie: {deviceName}", "deviceWantsAccess": "chce uzyskać dostęp do Twojego konta", "deviceExistingAccess": "Istniejący dostęp:", "deviceFullAccess": "Pełny dostęp do Twojego konta", "deviceOrganizationsAccess": "Dostęp do wszystkich organizacji, do których Twoje konto ma dostęp", "deviceAuthorize": "Autoryzuj {applicationName}", "deviceConnected": "Urządzenie podłączone!", "deviceAuthorizedMessage": "Urządzenie jest autoryzowane do uzyskania dostępu do Twojego konta. Proszę wróć do aplikacji klienckiej.", "pangolinCloud": "Chmura Pangolin", "viewDevices": "Zobacz urządzenia", "viewDevicesDescription": "Zarządzaj podłączonymi urządzeniami", "noDevices": "Nie znaleziono urządzeń", "dateCreated": "Data utworzenia", "unnamedDevice": "Urządzenie bez nazwy", "deviceQuestionRemove": "Czy na pewno chcesz usunąć to urządzenie?", "deviceMessageRemove": "Tej czynności nie można cofnąć.", "deviceDeleteConfirm": "Usuń urządzenie", "deleteDevice": "Usuń urządzenie", "errorLoadingDevices": "Błąd ładowania urządzeń", "failedToLoadDevices": "Nie udało się załadować urządzeń", "deviceDeleted": "Urządzenie usunięte", "deviceDeletedDescription": "Urządzenie zostało usunięte.", "errorDeletingDevice": "Błąd podczas usuwania urządzenia", "failedToDeleteDevice": "Nie udało się usunąć urządzenia", "showColumns": "Pokaż kolumny", "hideColumns": "Ukryj kolumny", "columnVisibility": "Widoczność kolumn", "toggleColumn": "Przełącz kolumnę {columnName}", "allColumns": "Wszystkie kolumny", "defaultColumns": "Kolumny domyślne", "customizeView": "Dostosuj widok", "viewOptions": "Opcje widoku", "selectAll": "Zaznacz wszystko", "selectNone": "Nie wybierz żadnego", "selectedResources": "Wybrane Zasoby", "enableSelected": "Włącz zaznaczone", "disableSelected": "Wyłącz zaznaczone", "checkSelectedStatus": "Sprawdź status zaznaczonych", "clients": "Klienty", "accessClientSelect": "Wybierz klientów komputera", "resourceClientDescription": "Klienci maszynowi, którzy mają dostęp do tego zasobu", "regenerate": "Wygeneruj ponownie", "credentials": "Dane logowania", "savecredentials": "Zapisz dane logowania", "regenerateCredentialsButton": "Wygeneruj dane logowania", "regenerateCredentials": "Wygeneruj dane logowania", "generatedcredentials": "Wygenerowane dane logowania", "copyandsavethesecredentials": "Skopiuj i zapisz te dane logowania", "copyandsavethesecredentialsdescription": "Te dane uwierzytelniające nie będą wyświetlane ponownie po opuszczeniu tej strony. Zapisz je teraz bezpiecznie.", "credentialsSaved": "Zapisano dane logowania", "credentialsSavedDescription": "Dane logowania zostały wygenerowane i zapisane pomyślnie.", "credentialsSaveError": "Błąd zapisu danych logowania", "credentialsSaveErrorDescription": "Wystąpił błąd podczas regeneracji i zapisywania poświadczeń.", "regenerateCredentialsWarning": "Regeneracja poświadczeń spowoduje unieważnienie poprzednich danych i spowoduje rozłączenie. Upewnij się, że aktualizacja wszystkich konfiguracji, które używają tych poświadczeń.", "confirm": "Potwierdź", "regenerateCredentialsConfirmation": "Czy na pewno chcesz wygenerować dane logowania?", "endpoint": "Endpoint", "Id": "Id", "SecretKey": "Sekretny klucz", "niceId": "Niepoprawne ID", "niceIdUpdated": "Zaktualizowano błędne ID", "niceIdUpdatedSuccessfully": "Zaktualizowano błędne ID", "niceIdUpdateError": "Błąd podczas aktualizacji Nice ID", "niceIdUpdateErrorDescription": "Wystąpił błąd podczas aktualizowania Nicei ID.", "niceIdCannotBeEmpty": "Niepoprawny identyfikator nie może być pusty", "enterIdentifier": "Wprowadź identyfikator", "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Nie ty? Użyj innego konta.", "deviceLoginDeviceRequestingAccessToAccount": "Urządzenie żąda dostępu do tego konta.", "loginSelectAuthenticationMethod": "Wybierz metodę uwierzytelniania aby kontynuować.", "noData": "Brak danych", "machineClients": "Klienci maszyn", "install": "Zainstaluj", "run": "Uruchom", "clientNameDescription": "Wyświetlana nazwa klienta, która może zostać zmieniona później.", "clientAddress": "Adres klienta (Zaawansowany)", "setupFailedToFetchSubnet": "Nie udało się pobrać domyślnej podsieci", "setupSubnetAdvanced": "Podsieć (zaawansowana)", "setupSubnetDescription": "Podsieć dla wewnętrznej sieci tej organizacji.", "setupUtilitySubnet": "Podsieć narzędziowa (zaawansowana)", "setupUtilitySubnetDescription": "Podsieć dla aliasów adresów i serwera DNS tej organizacji.", "siteRegenerateAndDisconnect": "Wygeneruj ponownie i rozłącz", "siteRegenerateAndDisconnectConfirmation": "Czy na pewno chcesz odzyskać dane logowania i odłączyć tę stronę?", "siteRegenerateAndDisconnectWarning": "Spowoduje to regenerację poświadczeń i natychmiastowe odłączenie witryny. Strona będzie musiała zostać zrestartowana z nowymi poświadczeniami.", "siteRegenerateCredentialsConfirmation": "Czy na pewno chcesz odzyskać dane logowania dla tej witryny?", "siteRegenerateCredentialsWarning": "Spowoduje to regenerację poświadczeń. Witryna pozostanie połączona dopóki nie uruchomisz jej ręcznie i nie użyjesz nowych poświadczeń.", "clientRegenerateAndDisconnect": "Wygeneruj ponownie i rozłącz", "clientRegenerateAndDisconnectConfirmation": "Czy na pewno chcesz odzyskać dane logowania i odłączyć tego klienta?", "clientRegenerateAndDisconnectWarning": "Spowoduje to regenerację poświadczeń i natychmiastowe odłączenie klienta. Klient będzie musiał zostać ponownie uruchomiony z nowymi poświadczeniami.", "clientRegenerateCredentialsConfirmation": "Czy na pewno chcesz odzyskać dane logowania dla tego klienta?", "clientRegenerateCredentialsWarning": "Spowoduje to regenerację poświadczeń. Klient pozostanie połączony dopóki nie uruchomisz go ponownie i nie użyjesz nowych poświadczeń.", "remoteExitNodeRegenerateAndDisconnect": "Wygeneruj ponownie i rozłącz", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Czy na pewno chcesz odzyskać dane logowania i odłączyć ten węzeł zdalnego wyjścia?", "remoteExitNodeRegenerateAndDisconnectWarning": "Spowoduje to regenerację danych logowania i natychmiastowe odłączenie zdalnego węzła wyjścia. Węzeł zdalnego wyjścia będzie musiał zostać ponownie uruchomiony z nowymi danymi logowania.", "remoteExitNodeRegenerateCredentialsConfirmation": "Czy na pewno chcesz wygenerować dane logowania dla tego węzła zdalnego wyjścia?", "remoteExitNodeRegenerateCredentialsWarning": "Spowoduje to regenerację poświadczeń. Serwer wyjścia pozostanie podłączony do momentu ręcznego ponownego uruchomienia i użycia nowych poświadczeń.", "agent": "Agent", "personalUseOnly": "Tylko do użytku osobistego", "loginPageLicenseWatermark": "Ta instancja jest licencjonowana tylko do użytku osobistego.", "instanceIsUnlicensed": "Ta instancja nie jest licencjonowana.", "portRestrictions": "Ograniczenia portu", "allPorts": "Wszystko", "custom": "Niestandardowe", "allPortsAllowed": "Wszystkie porty dozwolone", "allPortsBlocked": "Wszystkie porty zablokowane", "tcpPortsDescription": "Określ, które porty TCP są dozwolone dla tego zasobu. Użyj '*' dla wszystkich portów, pozostaw puste, aby zablokować wszystkie lub wpisz listę portów i zakresów oddzielonych przecinkami (np. 80,443,8000-9000).", "udpPortsDescription": "Określ, które porty UDP są dozwolone dla tego zasobu. Użyj '*' dla wszystkich portów, pozostaw puste, aby zablokować wszystkie lub wpisz listę portów i zakresów oddzielonych przecinkami (np. 53,123,500-600).", "organizationLoginPageTitle": "Strona logowania organizacji", "organizationLoginPageDescription": "Dostosuj stronę logowania dla tej organizacji", "resourceLoginPageTitle": "Strona logowania zasobów", "resourceLoginPageDescription": "Dostosuj stronę logowania dla poszczególnych zasobów", "enterConfirmation": "Wprowadź potwierdzenie", "blueprintViewDetails": "Szczegóły", "defaultIdentityProvider": "Domyślny dostawca tożsamości", "defaultIdentityProviderDescription": "Gdy zostanie wybrany domyślny dostawca tożsamości, użytkownik zostanie automatycznie przekierowany do dostawcy w celu uwierzytelnienia.", "editInternalResourceDialogNetworkSettings": "Ustawienia sieci", "editInternalResourceDialogAccessPolicy": "Polityka dostępowa", "editInternalResourceDialogAddRoles": "Dodaj role", "editInternalResourceDialogAddUsers": "Dodaj użytkowników", "editInternalResourceDialogAddClients": "Dodaj klientów", "editInternalResourceDialogDestinationLabel": "Miejsce docelowe", "editInternalResourceDialogDestinationDescription": "Określ adres docelowy dla wewnętrznego zasobu. Może to być nazwa hosta, adres IP lub zakres CIDR, w zależności od wybranego trybu. Opcjonalnie ustaw wewnętrzny alias DNS dla łatwiejszej identyfikacji.", "editInternalResourceDialogPortRestrictionsDescription": "Ogranicz dostęp do konkretnych portów TCP/UDP lub zezwól/zablokuj wszystkie porty.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Kontrola dostępu", "editInternalResourceDialogAccessControlDescription": "Kontroluj, które role, użytkownicy i klienci maszyn mają dostęp do tego zasobu po połączeniu. Administratorzy zawsze mają dostęp.", "editInternalResourceDialogPortRangeValidationError": "Zakres portów musi być \"*\" dla wszystkich portów lub listą portów i zakresów oddzielonych przecinkami (np. \"80,443,8000-9000\"). Porty muszą znajdować się w przedziale od 1 do 65535.", "internalResourceAuthDaemonStrategy": "SSH Auth Daemon Lokalizacja", "internalResourceAuthDaemonStrategyDescription": "Wybierz, gdzie działa demon uwierzytelniania SSH: na stronie (Newt) lub na zdalnym serwerze.", "internalResourceAuthDaemonDescription": "Uwierzytelnianie SSH obsługuje podpisywanie klucza SSH i uwierzytelnianie PAM dla tego zasobu. Wybierz, czy działa na stronie (Newt), czy na oddzielnym serwerze zdalnym. Zobacz dokumentację dla więcej.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Wybierz strategię", "internalResourceAuthDaemonStrategyLabel": "Lokalizacja", "internalResourceAuthDaemonSite": "Na stronie", "internalResourceAuthDaemonSiteDescription": "Demon Auth działa na stronie (nowy).", "internalResourceAuthDaemonRemote": "Zdalny host", "internalResourceAuthDaemonRemoteDescription": "Demon Auth działa na serwerze, który nie jest stroną.", "internalResourceAuthDaemonPort": "Port Daemon (opcjonalnie)", "orgAuthWhatsThis": "Gdzie mogę znaleźć swój identyfikator organizacji?", "learnMore": "Dowiedz się więcej", "backToHome": "Wróć do strony głównej", "needToSignInToOrg": "Czy potrzebujesz użyć dostawcy tożsamości organizacji?", "maintenanceMode": "Tryb konserwacji", "maintenanceModeDescription": "Wyświetl stronę konserwacyjną odwiedzającym", "maintenanceModeType": "Typ trybu konserwacji", "showMaintenancePage": "Pokaż odwiedzającym stronę konserwacji", "enableMaintenanceMode": "Włącz tryb konserwacji", "automatic": "Automatycznie", "automaticModeDescription": "Pokaż stronę konserwacyjną tylko wtedy, gdy wszystkie cele zaplecza są wyłączone lub niezdrowe. Twój zasób działa nadal normalnie, o ile przynajmniej jeden cel jest zdrowy.", "forced": "Wymuszone", "forcedModeDescription": "Zawsze pokazuj stronę konserwacyjną, niezależnie od stanu zdrowia zaplecza. Użyj tego w przypadku planowanej konserwacji, gdy chcesz zapobiec wszelkiemu dostępowi.", "warning:": "Ostrzeżenie:", "forcedeModeWarning": "Cały ruch zostanie skierowany na stronę konserwacyjną. Twoje zasoby zaplecza nie otrzymają żadnych żądań.", "pageTitle": "Tytuł strony", "pageTitleDescription": "Główny nagłówek wyświetlany na stronie konserwacyjnej", "maintenancePageMessage": "Komunikat konserwacyjny", "maintenancePageMessagePlaceholder": "Wrócimy wkrótce! Nasza strona przechodzi obecnie zaplanowaną konserwację.", "maintenancePageMessageDescription": "Szczegółowy komunikat wyjaśniający konserwację", "maintenancePageTimeTitle": "Szacowany czas zakończenia (opcjonalnie)", "maintenanceTime": "np. 2 godziny, 1 listopad o 17:00", "maintenanceEstimatedTimeDescription": "Kiedy oczekujesz zakończenia konserwacji", "editDomain": "Edytuj domenę", "editDomainDescription": "Wybierz domenę dla swojego zasobu", "maintenanceModeDisabledTooltip": "Ta funkcja wymaga ważnej licencji do aktywacji.", "maintenanceScreenTitle": "Usługa chwilowo niedostępna", "maintenanceScreenMessage": "Obecnie doświadczamy problemów technicznych. Proszę sprawdzić ponownie wkrótce.", "maintenanceScreenEstimatedCompletion": "Szacowane zakończenie:", "createInternalResourceDialogDestinationRequired": "Miejsce docelowe jest wymagane", "available": "Dostępny", "archived": "Zarchiwizowane", "noArchivedDevices": "Nie znaleziono zarchiwizowanych urządzeń", "deviceArchived": "Urządzenie zarchiwizowane", "deviceArchivedDescription": "Urządzenie zostało pomyślnie zarchiwizowane.", "errorArchivingDevice": "Błąd podczas archiwizacji urządzenia", "failedToArchiveDevice": "Nie udało się zarchiwizować urządzenia", "deviceQuestionArchive": "Czy na pewno chcesz zarchiwizować to urządzenie?", "deviceMessageArchive": "Urządzenie zostanie zarchiwizowane i usunięte z listy aktywnych urządzeń.", "deviceArchiveConfirm": "Archiwizuj urządzenie", "archiveDevice": "Archiwizuj urządzenie", "archive": "Archiwum", "deviceUnarchived": "Urządzenie niezarchiwizowane", "deviceUnarchivedDescription": "Urządzenie zostało pomyślnie usunięte.", "errorUnarchivingDevice": "Błąd podczas usuwania archiwizacji urządzenia", "failedToUnarchiveDevice": "Nie udało się odarchiwizować urządzenia", "unarchive": "Usuń z archiwum", "archiveClient": "Zarchiwizuj klienta", "archiveClientQuestion": "Czy na pewno chcesz zarchiwizować tego klienta?", "archiveClientMessage": "Klient zostanie zarchiwizowany i usunięty z listy aktywnych klientów.", "archiveClientConfirm": "Zarchiwizuj klienta", "blockClient": "Zablokuj klienta", "blockClientQuestion": "Czy na pewno chcesz zablokować tego klienta?", "blockClientMessage": "Urządzenie zostanie wymuszone do rozłączenia, jeśli jest obecnie podłączone. Możesz odblokować urządzenie później.", "blockClientConfirm": "Zablokuj klienta", "active": "Aktywne", "usernameOrEmail": "Nazwa użytkownika lub e-mail", "selectYourOrganization": "Wybierz swoją organizację", "signInTo": "Zaloguj się do", "signInWithPassword": "Kontynuuj z hasłem", "noAuthMethodsAvailable": "Brak dostępnych metod uwierzytelniania dla tej organizacji.", "enterPassword": "Wprowadź hasło", "enterMfaCode": "Wprowadź kod z aplikacji uwierzytelniającej", "securityKeyRequired": "Aby się zalogować, użyj klucza bezpieczeństwa.", "needToUseAnotherAccount": "Potrzebujesz użyć innego konta?", "loginLegalDisclaimer": "Klikając na przycisk poniżej, potwierdzasz, że przeczytałeś, rozumiesz, i zaakceptuj Warunki świadczenia usługi i Polityka prywatności.", "termsOfService": "Warunki korzystania z usługi", "privacyPolicy": "Polityka prywatności", "userNotFoundWithUsername": "Nie znaleziono użytkownika o tej nazwie użytkownika.", "verify": "Weryfikacja", "signIn": "Zaloguj się", "forgotPassword": "Zapomniałeś hasła?", "orgSignInTip": "Jeśli zalogowałeś się wcześniej, możesz wprowadzić nazwę użytkownika lub e-mail powyżej, aby uwierzytelnić się z dostawcą tożsamości organizacji. To łatwiejsze!", "continueAnyway": "Kontynuuj mimo to", "dontShowAgain": "Nie pokazuj ponownie", "orgSignInNotice": "Czy wiedziałeś?", "signupOrgNotice": "Próbujesz się zalogować?", "signupOrgTip": "Czy próbujesz zalogować się za pośrednictwem dostawcy tożsamości organizacji?", "signupOrgLink": "Zamiast tego zaloguj się lub zarejestruj w swojej organizacji", "verifyEmailLogInWithDifferentAccount": "Użyj innego konta", "logIn": "Zaloguj się", "deviceInformation": "Informacje o urządzeniu", "deviceInformationDescription": "Informacje o urządzeniu i agentach", "deviceSecurity": "Bezpieczeństwo urządzenia", "deviceSecurityDescription": "Informacje o bezpieczeństwie urządzenia", "platform": "Platforma", "macosVersion": "Wersja macOS", "windowsVersion": "Wersja Windows", "iosVersion": "Wersja iOS", "androidVersion": "Wersja Androida", "osVersion": "Wersja systemu operacyjnego", "kernelVersion": "Wersja jądra", "deviceModel": "Model urządzenia", "serialNumber": "Numer seryjny", "hostname": "Hostname", "firstSeen": "Widziany po raz pierwszy", "lastSeen": "Ostatnio widziane", "biometricsEnabled": "Biometria włączona", "diskEncrypted": "Dysk zaszyfrowany", "firewallEnabled": "Zapora włączona", "autoUpdatesEnabled": "Automatyczne aktualizacje włączone", "tpmAvailable": "TPM dostępne", "windowsAntivirusEnabled": "Antywirus włączony", "macosSipEnabled": "Ochrona integralności systemu (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Tryb Stealth zapory", "linuxAppArmorEnabled": "Zbroja aplikacji", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "Wyświetl informacje o urządzeniu i ustawienia", "devicePendingApprovalDescription": "To urządzenie czeka na zatwierdzenie", "deviceBlockedDescription": "To urządzenie jest obecnie zablokowane. Nie będzie można połączyć się z żadnymi zasobami, chyba że zostanie odblokowane.", "unblockClient": "Odblokuj klienta", "unblockClientDescription": "Urządzenie zostało odblokowane", "unarchiveClient": "Usuń archiwizację klienta", "unarchiveClientDescription": "Urządzenie zostało odarchiwizowane", "block": "Blok", "unblock": "Odblokuj", "deviceActions": "Akcje urządzenia", "deviceActionsDescription": "Zarządzaj stanem urządzenia i dostępem", "devicePendingApprovalBannerDescription": "To urządzenie oczekuje na zatwierdzenie. Nie będzie można połączyć się z zasobami, dopóki nie zostanie zatwierdzone.", "connected": "Połączono", "disconnected": "Rozłączony", "approvalsEmptyStateTitle": "Zatwierdzanie urządzenia nie włączone", "approvalsEmptyStateDescription": "Włącz zatwierdzanie urządzeń dla ról aby wymagać zgody administratora, zanim użytkownicy będą mogli podłączyć nowe urządzenia.", "approvalsEmptyStateStep1Title": "Przejdź do ról", "approvalsEmptyStateStep1Description": "Przejdź do ustawień ról swojej organizacji, aby skonfigurować zatwierdzenia urządzenia.", "approvalsEmptyStateStep2Title": "Włącz zatwierdzanie urządzenia", "approvalsEmptyStateStep2Description": "Edytuj rolę i włącz opcję \"Wymagaj zatwierdzenia urządzenia\". Użytkownicy z tą rolą będą potrzebowali zatwierdzenia administratora dla nowych urządzeń.", "approvalsEmptyStatePreviewDescription": "Podgląd: Gdy włączone, oczekujące prośby o sprawdzenie pojawią się tutaj", "approvalsEmptyStateButtonText": "Zarządzaj rolami" } ================================================ FILE: messages/pt-PT.json ================================================ { "setupCreate": "Criar a organização, o site e os recursos", "headerAuthCompatibilityInfo": "Habilite isso para forçar uma resposta 401 Unauthorized quando um token de autenticação estiver faltando. Isso é necessário para navegadores ou bibliotecas HTTP específicas que não enviam credenciais sem um desafio do servidor.", "headerAuthCompatibility": "Compatibilidade Estendida", "setupNewOrg": "Nova organização", "setupCreateOrg": "Criar Organização", "setupCreateResources": "Criar recursos", "setupOrgName": "Nome Da Organização", "orgDisplayName": "Este é o nome de exibição da organização.", "orgId": "ID da organização", "setupIdentifierMessage": "Este é o identificador exclusivo da organização.", "setupErrorIdentifier": "O ID da organização já existe. Por favor, escolha um diferente.", "componentsErrorNoMemberCreate": "Não é atualmente um membro de nenhuma organização. Crie uma organização para começar.", "componentsErrorNoMember": "Não é atualmente um membro de nenhuma organização.", "welcome": "Bem-vindo ao Pangolin", "welcomeTo": "Bem-vindo ao", "componentsCreateOrg": "Criar uma organização", "componentsMember": "É membro de {count, plural, =0 {nenhuma organização} one {uma organização} other {# organizações}}.", "componentsInvalidKey": "Chaves de licença inválidas ou expiradas detectadas. Siga os termos da licença para continuar usando todos os recursos.", "dismiss": "Rejeitar", "subscriptionViolationMessage": "Você está além dos seus limites para o seu plano atual. Corrija o problema removendo sites, usuários, ou outros recursos para ficar em seu plano.", "subscriptionViolationViewBilling": "Ver faturamento", "componentsLicenseViolation": "Violação de Licença: Este servidor está usando sites {usedSites} que excedem o limite licenciado de sites {maxSites} . Siga os termos da licença para continuar usando todos os recursos.", "componentsSupporterMessage": "Obrigado por apoiar o Pangolin como um {tier}!", "inviteErrorNotValid": "Desculpe, mas parece que o convite que está a tentar aceder não foi aceito ou não é mais válido.", "inviteErrorUser": "Lamentamos, mas parece que o convite que está a tentar aceder não é para este utilizador.", "inviteLoginUser": "Verifique se você está logado como o utilizador correto.", "inviteErrorNoUser": "Desculpe, mas parece que o convite que está a tentar aceder não é para um utilizador que existe.", "inviteCreateUser": "Por favor, crie uma conta primeiro.", "goHome": "Voltar ao inicio", "inviteLogInOtherUser": "Fazer login como um utilizador diferente", "createAnAccount": "Crie uma conta", "inviteNotAccepted": "Convite não aceite", "authCreateAccount": "Crie uma conta para começar", "authNoAccount": "Não possui uma conta?", "email": "e-mail", "password": "Palavra-passe", "confirmPassword": "Confirmar senha", "createAccount": "Criar conta", "viewSettings": "Ver Configurações", "delete": "apagar", "name": "Nome:", "online": "Disponível", "offline": "Desconectado", "site": "site", "dataIn": "Dados de entrada", "dataOut": "Dados de saída", "connectionType": "Tipo de conexão", "tunnelType": "Tipo de túnel", "local": "Localização", "edit": "Alterar", "siteConfirmDelete": "Confirmar que pretende apagar o site", "siteDelete": "Excluir site", "siteMessageRemove": "Uma vez removido, o site não estará mais acessível. Todas as metas associadas ao site também serão removidas.", "siteQuestionRemove": "Você tem certeza que deseja remover este site da organização?", "siteManageSites": "Gerir sites", "siteDescription": "Criar e gerenciar sites para ativar a conectividade a redes privadas", "sitesBannerTitle": "Conectar a Qualquer Rede", "sitesBannerDescription": "Um site é uma conexão a uma rede remota que permite ao Pangolin fornecer acesso a recursos, sejam eles públicos ou privados, a usuários em qualquer lugar. Instale o conector de rede do site (Newt) em qualquer lugar onde você possa executar um binário ou contêiner para estabelecer a conexão.", "sitesBannerButtonText": "Instalar Site", "approvalsBannerTitle": "Aprovar ou negar acesso ao dispositivo", "approvalsBannerDescription": "Revisar e aprovar ou negar solicitações de acesso ao dispositivo de usuários. Quando as aprovações do dispositivo são necessárias, os usuários devem obter a aprovação do administrador antes que seus dispositivos possam se conectar aos recursos da sua organização.", "approvalsBannerButtonText": "Saiba mais", "siteCreate": "Criar site", "siteCreateDescription2": "Siga os passos abaixo para criar e conectar um novo site", "siteCreateDescription": "Crie um novo site para começar a conectar os recursos", "close": "FECHAR", "siteErrorCreate": "Erro ao criar site", "siteErrorCreateKeyPair": "Par de chaves ou padrões do site não encontrados", "siteErrorCreateDefaults": "Padrão do site não encontrado", "method": "Método", "siteMethodDescription": "É assim que você irá expor as conexões.", "siteLearnNewt": "Saiba como instalar o Newt no seu sistema", "siteSeeConfigOnce": "Você só poderá ver a configuração uma vez.", "siteLoadWGConfig": "Carregando configuração do WireGuarde...", "siteDocker": "Expandir para detalhes da implantação Docker", "toggle": "Alternador", "dockerCompose": "Composição do Docker", "dockerRun": "Execução do Docker", "siteLearnLocal": "Os sites locais não são túneis, saiba mais", "siteConfirmCopy": "Eu copiei a configuração", "searchSitesProgress": "Procurar sites...", "siteAdd": "Adicionar Site", "siteInstallNewt": "Instalar Novo", "siteInstallNewtDescription": "Novo item em execução no seu sistema", "WgConfiguration": "Configuração do WireGuard", "WgConfigurationDescription": "Use a seguinte configuração para conectar-se à rede", "operatingSystem": "Sistema operacional", "commands": "Comandos", "recommended": "Recomendados", "siteNewtDescription": "Para a melhor experiência do utilizador, utilize Novo. Ele usa o WireGuard sob o capuz e permite que você aborde seus recursos privados através dos endereços LAN em sua rede privada do painel do Pangolin.", "siteRunsInDocker": "Executa no Docker", "siteRunsInShell": "Executa na shell no macOS, Linux e Windows", "siteErrorDelete": "Erro ao apagar site", "siteErrorUpdate": "Falha ao atualizar site", "siteErrorUpdateDescription": "Ocorreu um erro ao atualizar o site.", "siteUpdated": "Site atualizado", "siteUpdatedDescription": "O site foi atualizado.", "siteGeneralDescription": "Configurar as configurações gerais para este site", "siteSettingDescription": "Configurar as configurações no site", "siteSetting": "Configurações do {siteName}", "siteNewtTunnel": "Novo Site (Recomendado)", "siteNewtTunnelDescription": "Maneira mais fácil de criar um ponto de entrada em qualquer rede. Nenhuma configuração extra.", "siteWg": "WireGuard Básico", "siteWgDescription": "Use qualquer cliente do WireGuard para estabelecer um túnel. Configuração manual NAT é necessária.", "siteWgDescriptionSaas": "Use qualquer cliente WireGuard para estabelecer um túnel. Configuração manual NAT necessária. SOMENTE FUNCIONA EM NODES AUTO-HOSPEDADOS", "siteLocalDescription": "Recursos locais apenas. Sem túneis.", "siteLocalDescriptionSaas": "Apenas recursos locais. Sem túneis. Apenas disponível em nós remotos.", "siteSeeAll": "Ver todos os sites", "siteTunnelDescription": "Determine como você deseja se conectar ao site", "siteNewtCredentials": "Credenciais", "siteNewtCredentialsDescription": "É assim que o site se autentica com o servidor", "remoteNodeCredentialsDescription": "É assim que o nó remoto se autenticará com o servidor", "siteCredentialsSave": "Salvar as Credenciais", "siteCredentialsSaveDescription": "Você só será capaz de ver esta vez. Certifique-se de copiá-lo para um lugar seguro.", "siteInfo": "Informações do Site", "status": "SItuação", "shareTitle": "Gerir links partilhados", "shareDescription": "Criar links compartilháveis para conceder acesso temporário ou permanente aos recursos do proxy", "shareSearch": "Pesquisar links de compartilhamento...", "shareCreate": "Criar Link de Compartilhamento", "shareErrorDelete": "Falha ao apagar o link", "shareErrorDeleteMessage": "Ocorreu um erro ao apagar o link", "shareDeleted": "Link excluído", "shareDeletedDescription": "O link foi eliminado", "shareTokenDescription": "O token de acesso pode ser passado de duas maneiras: como um parâmetro de consulta ou nos cabeçalhos da solicitação. Estes devem ser passados do cliente em todas as solicitações para acesso autenticado.", "accessToken": "Token de acesso", "usageExamples": "Exemplos de uso", "tokenId": "ID do Token", "requestHeades": "Cabeçalhos de solicitação", "queryParameter": "Parâmetro de consulta", "importantNote": "Nota importante", "shareImportantDescription": "Por razões de segurança, o uso de cabeçalhos é recomendado através dos parâmetros de consulta quando possível, já que os parâmetros de consulta podem estar logados nos logs do servidor ou no histórico do navegador.", "token": "Identificador", "shareTokenSecurety": "Mantenha o token de acesso seguro. Não o compartilhe em áreas de acesso público ou código do lado do cliente.", "shareErrorFetchResource": "Falha ao buscar recursos", "shareErrorFetchResourceDescription": "Ocorreu um erro ao obter os recursos", "shareErrorCreate": "Falha ao criar link de compartilhamento", "shareErrorCreateDescription": "Ocorreu um erro ao criar o link de compartilhamento", "shareCreateDescription": "Qualquer um com este link pode aceder o recurso", "shareTitleOptional": "Título (opcional)", "expireIn": "Expira em", "neverExpire": "Nunca expirar", "shareExpireDescription": "Tempo de expiração é quanto tempo o link será utilizável e oferecerá acesso ao recurso. Após este tempo, o link não funcionará mais, e os utilizadores que usaram este link perderão acesso ao recurso.", "shareSeeOnce": "Você só poderá ver este link uma vez. Certifique-se de copiá-lo.", "shareAccessHint": "Qualquer um com este link pode aceder o recurso. Compartilhe com cuidado.", "shareTokenUsage": "Ver Uso do Token de Acesso", "createLink": "Criar Link", "resourcesNotFound": "Nenhum recurso encontrado", "resourceSearch": "Recursos de pesquisa", "openMenu": "Abrir menu", "resource": "Recurso", "title": "Título", "created": "Criado", "expires": "Expira", "never": "nunca", "shareErrorSelectResource": "Por favor, selecione um recurso", "proxyResourceTitle": "Gerenciar Recursos Públicos", "proxyResourceDescription": "Criar e gerenciar recursos que são acessíveis publicamente por meio de um navegador da web", "proxyResourcesBannerTitle": "Acesso Público via Web", "proxyResourcesBannerDescription": "Os recursos públicos são proxies HTTPS ou TCP/UDP acessíveis a qualquer pessoa na internet por meio de um navegador web. Ao contrário dos recursos privados, eles não requerem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.", "clientResourceTitle": "Gerenciar recursos privados", "clientResourceDescription": "Criar e gerenciar recursos que só são acessíveis por meio de um cliente conectado", "privateResourcesBannerTitle": "Acesso Privado com Confiança Zero", "privateResourcesBannerDescription": "Os recursos privados usam segurança de zero confiança, garantindo que usuários e máquinas possam acessar apenas os recursos que você concede explicitamente. Conecte dispositivos de usuários ou clientes de máquinas para acessar esses recursos por meio de uma rede privada virtual segura.", "resourcesSearch": "Procurar recursos...", "resourceAdd": "Adicionar Recurso", "resourceErrorDelte": "Erro ao apagar recurso", "authentication": "Autenticação", "protected": "Protegido", "notProtected": "Não Protegido", "resourceMessageRemove": "Uma vez removido, o recurso não estará mais acessível. Todos os alvos associados ao recurso também serão removidos.", "resourceQuestionRemove": "Você tem certeza que deseja remover o recurso da organização?", "resourceHTTP": "Recurso HTTPS", "resourceHTTPDescription": "Proxies requests sobre HTTPS usando um nome de domínio totalmente qualificado.", "resourceRaw": "Recurso TCP/UDP bruto", "resourceRawDescription": "Proxies solicitações sobre TCP/UDP bruto usando um número de porta.", "resourceRawDescriptionCloud": "Proxy solicita sobre TCP/UDP bruto usando um número de porta. OBRIGATÓRIO O USO DE UMA NOTA REMOTA.", "resourceCreate": "Criar Recurso", "resourceCreateDescription": "Siga os passos abaixo para criar um novo recurso", "resourceSeeAll": "Ver todos os recursos", "resourceInfo": "Informação do recurso", "resourceNameDescription": "Este é o nome de exibição para o recurso.", "siteSelect": "Selecionar site", "siteSearch": "Procurar no site", "siteNotFound": "Nenhum site encontrado.", "selectCountry": "Selecionar país", "searchCountries": "Buscar países...", "noCountryFound": "Nenhum país encontrado.", "siteSelectionDescription": "Este site fornecerá conectividade ao destino.", "resourceType": "Tipo de Recurso", "resourceTypeDescription": "Determine como acessar o recurso", "resourceHTTPSSettings": "Configurações de HTTPS", "resourceHTTPSSettingsDescription": "Configure como o recurso será acessado por HTTPS", "domainType": "Tipo de domínio", "subdomain": "Subdomínio", "baseDomain": "Domínio Base", "subdomnainDescription": "O subdomínio onde o recurso será acessível.", "resourceRawSettings": "Configurações TCP/UDP", "resourceRawSettingsDescription": "Configurar como o recurso será acessado sobre TCP/UDP", "protocol": "Protocolo", "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", "resourceAddEntrypoints": "Traefik: Adicionar pontos de entrada", "resourceExposePorts": "Gerbil: Expor Portas no Docker Compose", "resourceLearnRaw": "Aprenda como configurar os recursos TCP/UDP", "resourceBack": "Voltar aos recursos", "resourceGoTo": "Ir para o Recurso", "resourceDelete": "Excluir Recurso", "resourceDeleteConfirm": "Confirmar que pretende apagar o recurso", "visibility": "Visibilidade", "enabled": "Ativado", "disabled": "Desabilitado", "general": "Gerais", "generalSettings": "Configurações Gerais", "proxy": "Proxy", "internal": "Interno", "rules": "Regras", "resourceSettingDescription": "Configure as configurações do recurso", "resourceSetting": "Configurações do {resourceName}", "alwaysAllow": "Autenticação de bypass", "alwaysDeny": "Bloquear Acesso", "passToAuth": "Passar para Autenticação", "orgSettingsDescription": "Configurar configurações da organização", "orgGeneralSettings": "Configurações da organização", "orgGeneralSettingsDescription": "Gerenciar os detalhes e a configuração da organização", "saveGeneralSettings": "Guardar configurações gerais", "saveSettings": "Guardar Configurações", "orgDangerZone": "Zona de Perigo", "orgDangerZoneDescription": "Uma vez que você exclui esta organização, não há volta. Por favor, tenha certeza.", "orgDelete": "Excluir Organização", "orgDeleteConfirm": "Confirmar que pretende apagar a organização", "orgMessageRemove": "Esta ação é irreversível e apagará todos os dados associados.", "orgMessageConfirm": "Para confirmar, digite o nome da organização abaixo.", "orgQuestionRemove": "Você tem certeza que deseja remover esta organização?", "orgUpdated": "Organização atualizada", "orgUpdatedDescription": "A organização foi atualizada.", "orgErrorUpdate": "Falha ao atualizar organização", "orgErrorUpdateMessage": "Ocorreu um erro ao atualizar a organização.", "orgErrorFetch": "Falha ao buscar organizações", "orgErrorFetchMessage": "Ocorreu um erro ao listar suas organizações", "orgErrorDelete": "Falha ao apagar organização", "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", "accessUsersDescription": "Convidar e gerenciar usuários com acesso a esta organização", "accessUsersSearch": "Procurar utilizadores...", "accessUserCreate": "Criar Usuário", "accessUserRemove": "Remover utilizador", "username": "Usuário:", "identityProvider": "Provedor de Identidade", "role": "Funções", "nameRequired": "O nome é obrigatório", "accessRolesManage": "Gerir Funções", "accessRolesDescription": "Criar e gerenciar funções para os usuários na organização", "accessRolesSearch": "Pesquisar funções...", "accessRolesAdd": "Adicionar função", "accessRoleDelete": "Excluir Papel", "accessApprovalsManage": "Gerenciar aprovações", "accessApprovalsDescription": "Visualizar e gerenciar aprovações pendentes para acesso a esta organização", "description": "Descrição:", "inviteTitle": "Convites Abertos", "inviteDescription": "Gerenciar convites para outros usuários participarem da organização", "inviteSearch": "Procurar convites...", "minutes": "minutos", "hours": "horas", "days": "dias", "weeks": "semanas", "months": "Meses", "years": "anos", "day": "{count, plural, one {# dia} other {# dias}}", "apiKeysTitle": "Informações da Chave API", "apiKeysConfirmCopy2": "Você deve confirmar que copiou a chave API.", "apiKeysErrorCreate": "Erro ao criar chave API", "apiKeysErrorSetPermission": "Erro ao definir permissões", "apiKeysCreate": "Gerar Chave API", "apiKeysCreateDescription": "Gerar uma nova chave de API para a organização", "apiKeysGeneralSettings": "Permissões", "apiKeysGeneralSettingsDescription": "Determine o que esta chave API pode fazer", "apiKeysList": "Nova chave de API", "apiKeysSave": "Salvar a chave API", "apiKeysSaveDescription": "Você só poderá ver isto uma vez. Certifique-se de copiá-la para um local seguro.", "apiKeysInfo": "A chave API é:", "apiKeysConfirmCopy": "Eu copiei a chave API", "generate": "Gerar", "done": "Concluído", "apiKeysSeeAll": "Ver Todas as Chaves API", "apiKeysPermissionsErrorLoadingActions": "Erro ao carregar ações da chave API", "apiKeysPermissionsErrorUpdate": "Erro ao definir permissões", "apiKeysPermissionsUpdated": "Permissões atualizadas", "apiKeysPermissionsUpdatedDescription": "As permissões foram atualizadas.", "apiKeysPermissionsGeneralSettings": "Permissões", "apiKeysPermissionsGeneralSettingsDescription": "Determine o que esta chave API pode fazer", "apiKeysPermissionsSave": "Guardar Permissões", "apiKeysPermissionsTitle": "Permissões", "apiKeys": "Chaves API", "searchApiKeys": "Pesquisar chaves API...", "apiKeysAdd": "Gerar Chave API", "apiKeysErrorDelete": "Erro ao apagar chave API", "apiKeysErrorDeleteMessage": "Erro ao apagar chave API", "apiKeysQuestionRemove": "Tem certeza que deseja remover a chave de API da organização?", "apiKeysMessageRemove": "Uma vez removida, a chave API não poderá mais ser utilizada.", "apiKeysDeleteConfirm": "Confirmar Exclusão da Chave API", "apiKeysDelete": "Excluir Chave API", "apiKeysManage": "Gerir Chaves API", "apiKeysDescription": "As chaves API são usadas para autenticar com a API de integração", "apiKeysSettings": "Configurações de {apiKeyName}", "userTitle": "Gerir Todos os Utilizadores", "userDescription": "Visualizar e gerir todos os utilizadores no sistema", "userAbount": "Sobre a Gestão de Usuário", "userAbountDescription": "Esta tabela exibe todos os objetos root do utilizador. Cada utilizador pode pertencer a várias organizações. Remover um utilizador de uma organização não exclui seu objeto de utilizador raiz - ele permanecerá no sistema. Para remover completamente um utilizador do sistema, você deve apagar seu objeto raiz usando a ação de apagar nesta tabela.", "userServer": "Utilizadores do Servidor", "userSearch": "Pesquisar utilizadores do servidor...", "userErrorDelete": "Erro ao apagar utilizador", "userDeleteConfirm": "Confirmar Exclusão do Usuário", "userDeleteServer": "Excluir utilizador do servidor", "userMessageRemove": "O utilizador será removido de todas as organizações e será completamente removido do servidor.", "userQuestionRemove": "Tem certeza que deseja excluir permanentemente o usuário do servidor?", "licenseKey": "Chave de Licença", "valid": "Válido", "numberOfSites": "Número de sites", "licenseKeySearch": "Pesquisar chaves da licença...", "licenseKeyAdd": "Adicionar chave de licença", "type": "tipo", "licenseKeyRequired": "A chave da licença é necessária", "licenseTermsAgree": "Você deve concordar com os termos da licença", "licenseErrorKeyLoad": "Falha ao carregar chaves de licença", "licenseErrorKeyLoadDescription": "Ocorreu um erro ao carregar a chave da licença.", "licenseErrorKeyDelete": "Falha ao apagar chave de licença", "licenseErrorKeyDeleteDescription": "Ocorreu um erro ao apagar a chave de licença.", "licenseKeyDeleted": "Chave da licença excluída", "licenseKeyDeletedDescription": "A chave da licença foi excluída.", "licenseErrorKeyActivate": "Falha ao ativar a chave de licença", "licenseErrorKeyActivateDescription": "Ocorreu um erro ao ativar a chave da licença.", "licenseAbout": "Sobre Licenciamento", "communityEdition": "Edição da Comunidade", "licenseAboutDescription": "Isto destina-se aos utilizadores empresariais e empresariais que estão a usar o Pangolin num ambiente comercial. Se você estiver usando o Pangolin para uso pessoal, você pode ignorar esta seção.", "licenseKeyActivated": "Chave de licença ativada", "licenseKeyActivatedDescription": "A chave de licença foi ativada com sucesso.", "licenseErrorKeyRecheck": "Falha ao verificar novamente as chaves de licença", "licenseErrorKeyRecheckDescription": "Ocorreu um erro ao reverificar a chave de licença.", "licenseErrorKeyRechecked": "Chaves de licença reverificadas", "licenseErrorKeyRecheckedDescription": "Todas as chaves de licença foram remarcadas", "licenseActivateKey": "Ativar Chave de Licença", "licenseActivateKeyDescription": "Insira uma chave de licença para ativá-la.", "licenseActivate": "Ativar Licença", "licenseAgreement": "Ao marcar esta caixa, você confirma que leu e concorda com os termos de licença correspondentes ao nível associado à sua chave de licença.", "fossorialLicense": "Ver Termos e Condições de Assinatura e Licença Fossorial", "licenseMessageRemove": "Isto irá remover a chave da licença e todas as permissões associadas concedidas por ela.", "licenseMessageConfirm": "Para confirmar, por favor, digite a chave de licença abaixo.", "licenseQuestionRemove": "Tem certeza que deseja excluir a chave de licença?", "licenseKeyDelete": "Excluir Chave de Licença", "licenseKeyDeleteConfirm": "Confirmar que pretende apagar a chave de licença", "licenseTitle": "Gerir Status da Licença", "licenseTitleDescription": "Visualizar e gerir chaves de licença no sistema", "licenseHost": "Licença do host", "licenseHostDescription": "Gerir a chave de licença principal do host.", "licensedNot": "Não Licenciado", "hostId": "ID do host", "licenseReckeckAll": "Verifique novamente todas as chaves", "licenseSiteUsage": "Uso de Sites", "licenseSiteUsageDecsription": "Exibir o número de sites utilizando esta licença.", "licenseNoSiteLimit": "Não há limite para o número de sites utilizando um host não licenciado.", "licensePurchase": "Comprar Licença", "licensePurchaseSites": "Comprar Sites Adicionais", "licenseSitesUsedMax": "{usedSites} de {maxSites} utilizados", "licenseSitesUsed": "{count, plural, =0 {# sites} one {# site} other {# sites}} no sistema.", "licensePurchaseDescription": "Escolha quantos sites você quer {selectedMode, select, license {Compre uma licença. Você sempre pode adicionar mais sites depois.} other {adicione à sua licença existente.}}", "licenseFee": "Taxa de licença", "licensePriceSite": "Preço por site", "total": "Total:", "licenseContinuePayment": "Continuar para o pagamento", "pricingPage": "Página de preços", "pricingPortal": "Ver Portal de Compra", "licensePricingPage": "Para os preços e descontos mais atualizados, por favor, visite ", "invite": "Convites", "inviteRegenerate": "Regenerar Convite", "inviteRegenerateDescription": "Revogar convite anterior e criar um novo", "inviteRemove": "Remover Convite", "inviteRemoveError": "Falha ao remover convite", "inviteRemoveErrorDescription": "Ocorreu um erro ao remover o convite.", "inviteRemoved": "Convite removido", "inviteRemovedDescription": "O convite para {email} foi removido.", "inviteQuestionRemove": "Tem certeza de que deseja remover o convite?", "inviteMessageRemove": "Uma vez removido, este convite não será mais válido. Você sempre pode convidar o utilizador novamente mais tarde.", "inviteMessageConfirm": "Para confirmar, digite o endereço de e-mail do convite abaixo.", "inviteQuestionRegenerate": "Tem certeza que deseja regenerar o convite{email, plural, ='' {}, other { para #}}? Isso irá revogar o convite anterior.", "inviteRemoveConfirm": "Confirmar Remoção do Convite", "inviteRegenerated": "Convite Regenerado", "inviteSent": "Um novo convite foi enviado para {email}.", "inviteSentEmail": "Enviar notificação por e-mail ao utilizador", "inviteGenerate": "Um novo convite foi gerado para {email}.", "inviteDuplicateError": "Convite Duplicado", "inviteDuplicateErrorDescription": "Já existe um convite para este utilizador.", "inviteRateLimitError": "Limite de Taxa Excedido", "inviteRateLimitErrorDescription": "Excedeu o limite de 3 regenerações por hora. Por favor, tente novamente mais tarde.", "inviteRegenerateError": "Falha ao Regenerar Convite", "inviteRegenerateErrorDescription": "Ocorreu um erro ao regenerar o convite.", "inviteValidityPeriod": "Período de Validade", "inviteValidityPeriodSelect": "Selecione o período de validade", "inviteRegenerateMessage": "O convite foi regenerado. O utilizador deve aceder o link abaixo para aceitar o convite.", "inviteRegenerateButton": "Regenerar", "expiresAt": "Expira em", "accessRoleUnknown": "Função Desconhecida", "placeholder": "Espaço reservado", "userErrorOrgRemove": "Falha ao remover utilizador", "userErrorOrgRemoveDescription": "Ocorreu um erro ao remover o utilizador.", "userOrgRemoved": "Usuário removido", "userOrgRemovedDescription": "O utilizador {email} foi removido da organização.", "userQuestionOrgRemove": "Você tem certeza que deseja remover este usuário da organização?", "userMessageOrgRemove": "Uma vez removido, este utilizador não terá mais acesso à organização. Você sempre pode reconvidá-lo depois, mas eles precisarão aceitar o convite novamente.", "userRemoveOrgConfirm": "Confirmar Remoção do Usuário", "userRemoveOrg": "Remover Usuário da Organização", "users": "Utilizadores", "accessRoleMember": "Membro", "accessRoleOwner": "Proprietário", "userConfirmed": "Confirmado", "idpNameInternal": "Interno", "emailInvalid": "Endereço de email inválido", "inviteValidityDuration": "Por favor, selecione uma duração", "accessRoleSelectPlease": "Por favor, selecione uma função", "usernameRequired": "Nome de utilizador é obrigatório", "idpSelectPlease": "Por favor, selecione um provedor de identidade", "idpGenericOidc": "Provedor genérico OAuth2/OIDC.", "accessRoleErrorFetch": "Falha ao buscar funções", "accessRoleErrorFetchDescription": "Ocorreu um erro ao buscar as funções", "idpErrorFetch": "Falha ao buscar provedores de identidade", "idpErrorFetchDescription": "Ocorreu um erro ao buscar provedores de identidade", "userErrorExists": "Usuário já existe", "userErrorExistsDescription": "Este utilizador já é membro da organização.", "inviteError": "Falha ao convidar utilizador", "inviteErrorDescription": "Ocorreu um erro ao convidar o utilizador", "userInvited": "Usuário Convidado", "userInvitedDescription": "O utilizador foi convidado com sucesso.", "userErrorCreate": "Falha ao criar utilizador", "userErrorCreateDescription": "Ocorreu um erro ao criar o utilizador", "userCreated": "Usuário criado", "userCreatedDescription": "O utilizador foi criado com sucesso.", "userTypeInternal": "Usuário Interno", "userTypeInternalDescription": "Convide um usuário para participar diretamente da organização.", "userTypeExternal": "Usuário Externo", "userTypeExternalDescription": "Criar um utilizador com um provedor de identidade externo.", "accessUserCreateDescription": "Siga os passos abaixo para criar um novo utilizador", "userSeeAll": "Ver Todos os Utilizadores", "userTypeTitle": "Tipo de Usuário", "userTypeDescription": "Determine como você deseja criar o utilizador", "userSettings": "Informações do Usuário", "userSettingsDescription": "Insira os detalhes para o novo utilizador", "inviteEmailSent": "Enviar e-mail de convite para o utilizador", "inviteValid": "Válido Por", "selectDuration": "Selecionar duração", "selectResource": "Selecionar Recurso", "filterByResource": "Filtrar por Recurso", "selectApprovalState": "Selecionar Estado de Aprovação", "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", "deniedApproval": "Aprovação Negada", "all": "Todos", "deny": "Recusar", "viewDetails": "Visualizar Detalhes", "requestingNewDeviceApproval": "solicitou um novo dispositivo", "resetFilters": "Redefinir filtros", "totalBlocked": "Solicitações bloqueadas pelo Pangolin", "totalRequests": "Total de pedidos", "requestsByCountry": "Solicitações por país", "requestsByDay": "Requisições Por Dia", "blocked": "Bloqueado", "allowed": "Permitido", "topCountries": "Principais países", "accessRoleSelect": "Selecionar função", "inviteEmailSentDescription": "Um e-mail foi enviado ao utilizador com o link de acesso abaixo. Eles devem aceder ao link para aceitar o convite.", "inviteSentDescription": "O utilizador foi convidado. Eles devem aceder ao link abaixo para aceitar o convite.", "inviteExpiresIn": "O convite expirará em {days, plural, one {# dia} other {# dias}}.", "idpTitle": "Informações Gerais", "idpSelect": "Selecione o provedor de identidade para o utilizador externo", "idpNotConfigured": "Nenhum provedor de identidade está configurado. Configure um provedor de identidade antes de criar utilizadores externos.", "usernameUniq": "Isto deve corresponder ao nome de utilizador único que existe no provedor de identidade selecionado.", "emailOptional": "E-mail (Opcional)", "nameOptional": "Nome (Opcional)", "accessControls": "Controlos de Acesso", "userDescription2": "Gerir as configurações deste utilizador", "accessRoleErrorAdd": "Falha ao adicionar utilizador à função", "accessRoleErrorAddDescription": "Ocorreu um erro ao adicionar utilizador à função.", "userSaved": "Usuário salvo", "userSavedDescription": "O utilizador foi atualizado.", "autoProvisioned": "Auto provisionado", "autoProvisionedDescription": "Permitir que este utilizador seja gerido automaticamente pelo provedor de identidade", "accessControlsDescription": "Gerir o que este utilizador pode aceder e fazer na organização", "accessControlsSubmit": "Guardar Controlos de Acesso", "roles": "Funções", "accessUsersRoles": "Gerir Utilizadores e Funções", "accessUsersRolesDescription": "Convidar usuários e adicioná-los a funções para gerenciar o acesso à organização", "key": "Chave", "createdAt": "Criado Em", "proxyErrorInvalidHeader": "Valor do cabeçalho Host personalizado inválido. Use o formato de nome de domínio ou salve vazio para remover o cabeçalho Host personalizado.", "proxyErrorTls": "Nome do Servidor TLS inválido. Use o formato de nome de domínio ou salve vazio para remover o Nome do Servidor TLS.", "proxyEnableSSL": "Habilitar SSL", "proxyEnableSSLDescription": "Habilitar criptografia SSL/TLS para conexões HTTPS seguras aos alvos.", "target": "Target", "configureTarget": "Configurar Alvos", "targetErrorFetch": "Falha ao buscar alvos", "targetErrorFetchDescription": "Ocorreu um erro ao buscar alvos", "siteErrorFetch": "Falha ao buscar recurso", "siteErrorFetchDescription": "Ocorreu um erro ao buscar recurso", "targetErrorDuplicate": "Alvo duplicado", "targetErrorDuplicateDescription": "Um alvo com estas configurações já existe", "targetWireGuardErrorInvalidIp": "IP do alvo inválido", "targetWireGuardErrorInvalidIpDescription": "O IP do alvo deve estar dentro da subnet do site", "targetsUpdated": "Alvos atualizados", "targetsUpdatedDescription": "Alvos e configurações atualizados com sucesso", "targetsErrorUpdate": "Falha ao atualizar alvos", "targetsErrorUpdateDescription": "Ocorreu um erro ao atualizar alvos", "targetTlsUpdate": "Configurações TLS atualizadas", "targetTlsUpdateDescription": "Configurações TLS foram atualizadas com sucesso", "targetErrorTlsUpdate": "Falha ao atualizar configurações TLS", "targetErrorTlsUpdateDescription": "Ocorreu um erro ao atualizar as configurações TLS", "proxyUpdated": "Configurações de proxy atualizadas", "proxyUpdatedDescription": "Configurações de proxy atualizadas com sucesso", "proxyErrorUpdate": "Falha ao atualizar configurações de proxy", "proxyErrorUpdateDescription": "Ocorreu um erro ao atualizar as configurações de proxy", "targetAddr": "Servidor", "targetPort": "Porta", "targetProtocol": "Protocolo", "targetTlsSettings": "Configuração de conexão segura", "targetTlsSettingsDescription": "Configurar configurações SSL/TLS para o recurso", "targetTlsSettingsAdvanced": "Configurações TLS Avançadas", "targetTlsSni": "Nome do Servidor TLS", "targetTlsSniDescription": "O Nome do Servidor TLS para usar para SNI. Deixe vazio para usar o padrão.", "targetTlsSubmit": "Guardar Configurações", "targets": "Configuração de Alvos", "targetsDescription": "Configurar alvos para tráfego de rota para serviços de backend", "targetStickySessions": "Ativar Sessões Persistentes", "targetStickySessionsDescription": "Manter conexões no mesmo alvo backend durante toda a sessão.", "methodSelect": "Selecionar método", "targetSubmit": "Adicionar Alvo", "targetNoOne": "Este recurso não tem nenhum alvo. Adicione um alvo para configurar para onde enviar solicitações para o backend.", "targetNoOneDescription": "Adicionar mais de um alvo acima habilitará o balanceamento de carga.", "targetsSubmit": "Guardar Alvos", "addTarget": "Adicionar Alvo", "targetErrorInvalidIp": "Endereço IP inválido", "targetErrorInvalidIpDescription": "Por favor, insira um endereço IP ou nome de host válido", "targetErrorInvalidPort": "Porta inválida", "targetErrorInvalidPortDescription": "Por favor, digite um número de porta válido", "targetErrorNoSite": "Nenhum site selecionado", "targetErrorNoSiteDescription": "Selecione um site para o destino", "targetCreated": "Destino criado", "targetCreatedDescription": "O alvo foi criado com sucesso", "targetErrorCreate": "Falha ao criar destino", "targetErrorCreateDescription": "Ocorreu um erro ao criar o destino", "tlsServerName": "Nome do Servidor TLS", "tlsServerNameDescription": "O nome do servidor TLS a ser usado para SNI", "save": "Guardar", "proxyAdditional": "Configurações Adicionais de Proxy", "proxyAdditionalDescription": "Configurar como o recurso lida com as configurações de proxy", "proxyCustomHeader": "Cabeçalho Host Personalizado", "proxyCustomHeaderDescription": "O cabeçalho host para definir ao fazer proxy de requisições. Deixe vazio para usar o padrão.", "proxyAdditionalSubmit": "Guardar Configurações de Proxy", "subnetMaskErrorInvalid": "Máscara de subnet inválida. Deve estar entre 0 e 32.", "ipAddressErrorInvalidFormat": "Formato de endereço IP inválido", "ipAddressErrorInvalidOctet": "Octeto de endereço IP inválido", "path": "Caminho", "matchPath": "Correspondência de caminho", "ipAddressRange": "Faixa de IP", "rulesErrorFetch": "Falha ao buscar regras", "rulesErrorFetchDescription": "Ocorreu um erro ao buscar regras", "rulesErrorDuplicate": "Regra duplicada", "rulesErrorDuplicateDescription": "Uma regra com estas configurações já existe", "rulesErrorInvalidIpAddressRange": "CIDR inválido", "rulesErrorInvalidIpAddressRangeDescription": "Por favor, insira um valor CIDR válido", "rulesErrorInvalidUrl": "Caminho URL inválido", "rulesErrorInvalidUrlDescription": "Por favor, insira um valor de caminho URL válido", "rulesErrorInvalidIpAddress": "IP inválido", "rulesErrorInvalidIpAddressDescription": "Por favor, insira um endereço IP válido", "rulesErrorUpdate": "Falha ao atualizar regras", "rulesErrorUpdateDescription": "Ocorreu um erro ao atualizar regras", "rulesUpdated": "Ativar Regras", "rulesUpdatedDescription": "A avaliação de regras foi atualizada", "rulesMatchIpAddressRangeDescription": "Insira um endereço no formato CIDR (ex: 103.21.244.0/22)", "rulesMatchIpAddress": "Insira um endereço IP (ex: 103.21.244.12)", "rulesMatchUrl": "Insira um caminho URL ou padrão (ex: /api/v1/todos ou /api/v1/*)", "rulesErrorInvalidPriority": "Prioridade Inválida", "rulesErrorInvalidPriorityDescription": "Por favor, insira uma prioridade válida", "rulesErrorDuplicatePriority": "Prioridades Duplicadas", "rulesErrorDuplicatePriorityDescription": "Por favor, insira prioridades únicas", "ruleUpdated": "Regras atualizadas", "ruleUpdatedDescription": "Regras atualizadas com sucesso", "ruleErrorUpdate": "Operação falhou", "ruleErrorUpdateDescription": "Ocorreu um erro durante a operação de salvamento", "rulesPriority": "Prioridade", "rulesAction": "Ação", "rulesMatchType": "Tipo de Correspondência", "value": "Valor", "rulesAbout": "Sobre Regras", "rulesAboutDescription": "Regras permitem que você controle o acesso ao recurso com base em um conjunto de critérios. Você pode criar regras para permitir ou negar acesso baseado no endereço IP ou caminho de URL.", "rulesActions": "Ações", "rulesActionAlwaysAllow": "Sempre Permitir: Ignorar todos os métodos de autenticação", "rulesActionAlwaysDeny": "Sempre Negar: Bloquear todas as requisições; nenhuma autenticação pode ser tentada", "rulesActionPassToAuth": "Passar para Autenticação: Permitir que métodos de autenticação sejam tentados", "rulesMatchCriteria": "Critérios de Correspondência", "rulesMatchCriteriaIpAddress": "Corresponder a um endereço IP específico", "rulesMatchCriteriaIpAddressRange": "Corresponder a uma faixa de endereços IP em notação CIDR", "rulesMatchCriteriaUrl": "Corresponder a um caminho URL ou padrão", "rulesEnable": "Ativar Regras", "rulesEnableDescription": "Ativar ou desativar avaliação de regras para este recurso", "rulesResource": "Configuração de Regras do Recurso", "rulesResourceDescription": "Configurar regras para controlar o acesso ao recurso", "ruleSubmit": "Adicionar Regra", "rulesNoOne": "Sem regras. Adicione uma regra usando o formulário.", "rulesOrder": "As regras são avaliadas por prioridade em ordem ascendente.", "rulesSubmit": "Guardar Regras", "resourceErrorCreate": "Erro ao criar recurso", "resourceErrorCreateDescription": "Ocorreu um erro ao criar o recurso", "resourceErrorCreateMessage": "Erro ao criar recurso:", "resourceErrorCreateMessageDescription": "Ocorreu um erro inesperado", "sitesErrorFetch": "Erro ao buscar sites", "sitesErrorFetchDescription": "Ocorreu um erro ao buscar os sites", "domainsErrorFetch": "Erro ao buscar domínios", "domainsErrorFetchDescription": "Ocorreu um erro ao buscar os domínios", "none": "Nenhum", "unknown": "Desconhecido", "resources": "Recursos", "resourcesDescription": "Os recursos são proxies para aplicativos em execução na rede privada. Crie um recurso para qualquer serviço HTTP/HTTPS ou TCP/UDP bruto na sua rede privada. Cada recurso deve ser conectado a um site para ativar a conectividade privada e segura através de um túnel do WireGuard criptografado.", "resourcesWireGuardConnect": "Conectividade segura com criptografia WireGuard", "resourcesMultipleAuthenticationMethods": "Configure múltiplos métodos de autenticação", "resourcesUsersRolesAccess": "Controle de acesso baseado em utilizadores e funções", "resourcesErrorUpdate": "Falha ao alternar recurso", "resourcesErrorUpdateDescription": "Ocorreu um erro ao atualizar o recurso", "access": "Acesso", "accessControl": "Controle de Acesso", "shareLink": "Link de Compartilhamento {resource}", "resourceSelect": "Selecionar recurso", "shareLinks": "Links de Compartilhamento", "share": "Links Compartilháveis", "shareDescription2": "Crie links compartilháveis para recursos. Links fornecem acesso temporário ou ilimitado ao seu recurso. Você pode configurar a duração de expiração do link quando você criar um.", "shareEasyCreate": "Fácil de criar e compartilhar", "shareConfigurableExpirationDuration": "Duração de expiração configurável", "shareSecureAndRevocable": "Seguro e revogável", "nameMin": "O nome deve ter pelo menos {len} caracteres.", "nameMax": "O nome não deve ter mais de {len} caracteres.", "sitesConfirmCopy": "Por favor, confirme que você copiou a configuração.", "unknownCommand": "Comando desconhecido", "newtErrorFetchReleases": "Falha ao buscar informações da versão: {err}", "newtErrorFetchLatest": "Erro ao buscar última versão: {err}", "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Chave Secreta", "architecture": "Arquitetura", "sites": "sites", "siteWgAnyClients": "Use qualquer cliente do WireGuard para se conectar. Você terá que endereçar recursos internos usando o IP de pares.", "siteWgCompatibleAllClients": "Compatível com todos os clientes WireGuard", "siteWgManualConfigurationRequired": "Configuração manual necessária", "userErrorNotAdminOrOwner": "Usuário não é administrador ou proprietário", "pangolinSettings": "Configurações - Pangolin", "accessRoleYour": "Sua função:", "accessRoleSelect2": "Selecionar funções", "accessUserSelect": "Selecionar os usuários", "otpEmailEnter": "Digite um e-mail", "otpEmailEnterDescription": "Pressione enter para adicionar um e-mail após digitá-lo no campo de entrada.", "otpEmailErrorInvalid": "Endereço de e-mail inválido. O caractere curinga (*) deve ser a parte local inteira.", "otpEmailSmtpRequired": "SMTP Necessário", "otpEmailSmtpRequiredDescription": "O SMTP deve estar habilitado no servidor para usar a autenticação de senha única.", "otpEmailTitle": "Senhas Únicas", "otpEmailTitleDescription": "Requer autenticação baseada em e-mail para acesso ao recurso", "otpEmailWhitelist": "Lista de E-mails Permitidos", "otpEmailWhitelistList": "E-mails na Lista Permitida", "otpEmailWhitelistListDescription": "Apenas utilizadores com estes endereços de e-mail poderão aceder este recurso. Eles serão solicitados a inserir uma senha única enviada para seu e-mail. Caracteres curinga (*@example.com) podem ser usados para permitir qualquer endereço de e-mail de um domínio.", "otpEmailWhitelistSave": "Guardar Lista Permitida", "passwordAdd": "Adicionar Senha", "passwordRemove": "Remover Senha", "pincodeAdd": "Adicionar Código PIN", "pincodeRemove": "Remover Código PIN", "resourceAuthMethods": "Métodos de Autenticação", "resourceAuthMethodsDescriptions": "Permitir acesso ao recurso via métodos de autenticação adicionais", "resourceAuthSettingsSave": "Salvo com sucesso", "resourceAuthSettingsSaveDescription": "As configurações de autenticação foram salvas", "resourceErrorAuthFetch": "Falha ao buscar dados", "resourceErrorAuthFetchDescription": "Ocorreu um erro ao buscar os dados", "resourceErrorPasswordRemove": "Erro ao remover senha do recurso", "resourceErrorPasswordRemoveDescription": "Ocorreu um erro ao remover a senha do recurso", "resourceErrorPasswordSetup": "Erro ao definir senha do recurso", "resourceErrorPasswordSetupDescription": "Ocorreu um erro ao definir a senha do recurso", "resourceErrorPincodeRemove": "Erro ao remover código PIN do recurso", "resourceErrorPincodeRemoveDescription": "Ocorreu um erro ao remover o código PIN do recurso", "resourceErrorPincodeSetup": "Erro ao definir código PIN do recurso", "resourceErrorPincodeSetupDescription": "Ocorreu um erro ao definir o código PIN do recurso", "resourceErrorUsersRolesSave": "Falha ao definir funções", "resourceErrorUsersRolesSaveDescription": "Ocorreu um erro ao definir as funções", "resourceErrorWhitelistSave": "Falha ao salvar lista permitida", "resourceErrorWhitelistSaveDescription": "Ocorreu um erro ao salvar a lista permitida", "resourcePasswordSubmit": "Habilitar Proteção por Senha", "resourcePasswordProtection": "Proteção com senha {status}", "resourcePasswordRemove": "Senha do recurso removida", "resourcePasswordRemoveDescription": "A senha do recurso foi removida com sucesso", "resourcePasswordSetup": "Senha do recurso definida", "resourcePasswordSetupDescription": "A senha do recurso foi definida com sucesso", "resourcePasswordSetupTitle": "Definir Senha", "resourcePasswordSetupTitleDescription": "Defina uma senha para proteger este recurso", "resourcePincode": "Código PIN", "resourcePincodeSubmit": "Habilitar Proteção por Código PIN", "resourcePincodeProtection": "Proteção por Código PIN {status}", "resourcePincodeRemove": "Código PIN do recurso removido", "resourcePincodeRemoveDescription": "O código PIN do recurso foi removido com sucesso", "resourcePincodeSetup": "Código PIN do recurso definido", "resourcePincodeSetupDescription": "O código PIN do recurso foi definido com sucesso", "resourcePincodeSetupTitle": "Definir Código PIN", "resourcePincodeSetupTitleDescription": "Defina um código PIN para proteger este recurso", "resourceRoleDescription": "Administradores sempre podem aceder este recurso.", "resourceUsersRoles": "Controlos de Acesso", "resourceUsersRolesDescription": "Configure quais utilizadores e funções podem visitar este recurso", "resourceUsersRolesSubmit": "Guardar Controlos de Acesso", "resourceWhitelistSave": "Salvo com sucesso", "resourceWhitelistSaveDescription": "As configurações da lista permitida foram salvas", "ssoUse": "Usar SSO da Plataforma", "ssoUseDescription": "Os utilizadores existentes só precisarão fazer login uma vez para todos os recursos que tiverem isso habilitado.", "proxyErrorInvalidPort": "Número da porta inválido", "subdomainErrorInvalid": "Subdomínio inválido", "domainErrorFetch": "Erro ao buscar domínios", "domainErrorFetchDescription": "Ocorreu um erro ao buscar os domínios", "resourceErrorUpdate": "Falha ao atualizar recurso", "resourceErrorUpdateDescription": "Ocorreu um erro ao atualizar o recurso", "resourceUpdated": "Recurso atualizado", "resourceUpdatedDescription": "O recurso foi atualizado com sucesso", "resourceErrorTransfer": "Falha ao transferir recurso", "resourceErrorTransferDescription": "Ocorreu um erro ao transferir o recurso", "resourceTransferred": "Recurso transferido", "resourceTransferredDescription": "O recurso foi transferido com sucesso", "resourceErrorToggle": "Falha ao alternar recurso", "resourceErrorToggleDescription": "Ocorreu um erro ao atualizar o recurso", "resourceVisibilityTitle": "Visibilidade", "resourceVisibilityTitleDescription": "Ativar ou desativar completamente a visibilidade do recurso", "resourceGeneral": "Configurações Gerais", "resourceGeneralDescription": "Configure as configurações gerais para este recurso", "resourceEnable": "Ativar Recurso", "resourceTransfer": "Transferir Recurso", "resourceTransferDescription": "Transferir este recurso para um site diferente", "resourceTransferSubmit": "Transferir Recurso", "siteDestination": "Site de Destino", "searchSites": "Pesquisar sites", "countries": "Países", "accessRoleCreate": "Criar Função", "accessRoleCreateDescription": "Crie uma nova função para agrupar utilizadores e gerir suas permissões.", "accessRoleEdit": "Editar Permissão", "accessRoleEditDescription": "Editar informações do papel.", "accessRoleCreateSubmit": "Criar Função", "accessRoleCreated": "Função criada", "accessRoleCreatedDescription": "A função foi criada com sucesso.", "accessRoleErrorCreate": "Falha ao criar função", "accessRoleErrorCreateDescription": "Ocorreu um erro ao criar a função.", "accessRoleUpdateSubmit": "Atualizar Função", "accessRoleUpdated": "Função atualizada", "accessRoleUpdatedDescription": "A função foi atualizada com sucesso.", "accessApprovalUpdated": "Aprovação processada", "accessApprovalApprovedDescription": "Definir decisão de solicitação de aprovação para aprovada.", "accessApprovalDeniedDescription": "Definir decisão de solicitação de aprovação para negada.", "accessRoleErrorUpdate": "Falha ao atualizar papel", "accessRoleErrorUpdateDescription": "Ocorreu um erro ao atualizar a função.", "accessApprovalErrorUpdate": "Não foi possível processar a aprovação", "accessApprovalErrorUpdateDescription": "Ocorreu um erro ao processar a aprovação.", "accessRoleErrorNewRequired": "Nova função é necessária", "accessRoleErrorRemove": "Falha ao remover função", "accessRoleErrorRemoveDescription": "Ocorreu um erro ao remover a função.", "accessRoleName": "Nome da Função", "accessRoleQuestionRemove": "Você está prestes a apagar o papel `{name}. Você não pode desfazer esta ação.", "accessRoleRemove": "Remover Função", "accessRoleRemoveDescription": "Remover uma função da organização", "accessRoleRemoveSubmit": "Remover Função", "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", "licenseTierProfessional": "Licença Profissional", "licenseTierEnterprise": "Licença Empresarial", "licenseTierPersonal": "Licença Pessoal", "licensed": "Licenciado", "yes": "Sim", "no": "Não", "sitesAdditional": "Sites Adicionais", "licenseKeys": "Chaves de Licença", "sitestCountDecrease": "Diminuir contagem de sites", "sitestCountIncrease": "Aumentar contagem de sites", "idpManage": "Gerir Provedores de Identidade", "idpManageDescription": "Visualizar e gerir provedores de identidade no sistema", "idpGlobalModeBanner": "Provedores de identidade (Pds) por organização estão desabilitados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie IdPs no painel admin. Para habilitar IdPs por organização, edite a configuração do servidor e defina o modo IdP como org. Veja a documentação. Se quiser continuar usando IdPs globais e fazer isso desaparecer das configurações da organização, defina explicitamente o modo como global na configuração.", "idpGlobalModeBannerUpgradeRequired": "Os provedores de identidade (IdPs) por organização estão desativados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie os IdPs globais no painel administrativo. Para usar provedores de identidade por organização, você deve atualizar para a edição Enterprise.", "idpGlobalModeBannerLicenseRequired": "Os provedores de identidade (IdPs) por organização estão desativados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie os IdPs globais no painel administrativo. Para usar provedores de identidade por organização, é necessário uma licença Enterprise.", "idpDeletedDescription": "Provedor de identidade eliminado com sucesso", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Tem certeza que deseja eliminar permanentemente o provedor de identidade?", "idpMessageRemove": "Isto irá remover o provedor de identidade e todas as configurações associadas. Os utilizadores que se autenticam através deste provedor não poderão mais fazer login.", "idpMessageConfirm": "Para confirmar, por favor digite o nome do provedor de identidade abaixo.", "idpConfirmDelete": "Confirmar Eliminação do Provedor de Identidade", "idpDelete": "Eliminar Provedor de Identidade", "idp": "Provedores de Identidade", "idpSearch": "Pesquisar provedores de identidade...", "idpAdd": "Adicionar Provedor de Identidade", "idpClientIdRequired": "O ID do Cliente é obrigatório.", "idpClientSecretRequired": "O Segredo do Cliente é obrigatório.", "idpErrorAuthUrlInvalid": "O URL de Autenticação deve ser um URL válido.", "idpErrorTokenUrlInvalid": "O URL do Token deve ser um URL válido.", "idpPathRequired": "O Caminho do Identificador é obrigatório.", "idpScopeRequired": "Os Escopos são obrigatórios.", "idpOidcDescription": "Configurar um provedor de identidade OpenID Connect", "idpCreatedDescription": "Provedor de identidade criado com sucesso", "idpCreate": "Criar Provedor de Identidade", "idpCreateDescription": "Configurar um novo provedor de identidade para autenticação de utilizadores", "idpSeeAll": "Ver Todos os Provedores de Identidade", "idpSettingsDescription": "Configurar as informações básicas para o seu provedor de identidade", "idpDisplayName": "Um nome de exibição para este provedor de identidade", "idpAutoProvisionUsers": "Provisionamento Automático de Utilizadores", "idpAutoProvisionUsersDescription": "Quando ativado, os utilizadores serão criados automaticamente no sistema no primeiro login com a capacidade de mapear utilizadores para funções e organizações.", "licenseBadge": "EE", "idpType": "Tipo de Provedor", "idpTypeDescription": "Selecione o tipo de provedor de identidade que deseja configurar", "idpOidcConfigure": "Configuração OAuth2/OIDC", "idpOidcConfigureDescription": "Configurar os endpoints e credenciais do provedor OAuth2/OIDC", "idpClientId": "ID do Cliente", "idpClientIdDescription": "O ID de cliente OAuth2 do provedor de identidade", "idpClientSecret": "Segredo do Cliente", "idpClientSecretDescription": "O segredo de cliente OAuth2 do provedor de identidade", "idpAuthUrl": "URL de Autorização", "idpAuthUrlDescription": "O URL do endpoint de autorização OAuth2", "idpTokenUrl": "URL do Token", "idpTokenUrlDescription": "O URL do endpoint do token OAuth2", "idpOidcConfigureAlert": "Informação Importante", "idpOidcConfigureAlertDescription": "Depois de criar o provedor de identidade, você precisará configurar o URL de retorno de chamada nas configurações do provedor de identidade. A URL de retorno de chamada será fornecida após a criação com sucesso.", "idpToken": "Configuração do Token", "idpTokenDescription": "Configurar como extrair informações do utilizador do token ID", "idpJmespathAbout": "Sobre JMESPath", "idpJmespathAboutDescription": "Os caminhos abaixo usam a sintaxe JMESPath para extrair valores do token ID.", "idpJmespathAboutDescriptionLink": "Saiba mais sobre JMESPath", "idpJmespathLabel": "Caminho do Identificador", "idpJmespathLabelDescription": "O JMESPath para o identificador do utilizador no token ID", "idpJmespathEmailPathOptional": "Caminho do Email (Opcional)", "idpJmespathEmailPathOptionalDescription": "O JMESPath para o email do utilizador no token ID", "idpJmespathNamePathOptional": "Caminho do Nome (Opcional)", "idpJmespathNamePathOptionalDescription": "O JMESPath para o nome do utilizador no token ID", "idpOidcConfigureScopes": "Escopos", "idpOidcConfigureScopesDescription": "Lista de escopos OAuth2 separados por espaço para solicitar", "idpSubmit": "Criar Provedor de Identidade", "orgPolicies": "Políticas da Organização", "idpSettings": "Configurações de {idpName}", "idpCreateSettingsDescription": "Configure as configurações para o provedor de identidade", "roleMapping": "Mapeamento de Funções", "orgMapping": "Mapeamento da Organização", "orgPoliciesSearch": "Pesquisar políticas da organização...", "orgPoliciesAdd": "Adicionar Política da Organização", "orgRequired": "A organização é obrigatória", "error": "Erro", "success": "Sucesso", "orgPolicyAddedDescription": "Política adicionada com sucesso", "orgPolicyUpdatedDescription": "Política atualizada com sucesso", "orgPolicyDeletedDescription": "Política eliminada com sucesso", "defaultMappingsUpdatedDescription": "Mapeamentos padrão atualizados com sucesso", "orgPoliciesAbout": "Sobre Políticas da Organização", "orgPoliciesAboutDescription": "As políticas da organização são usadas para controlar o acesso às organizações com base no token ID do utilizador. Pode especificar expressões JMESPath para extrair informações de função e organização do token ID. Para mais informações, consulte", "orgPoliciesAboutDescriptionLink": "a documentação", "defaultMappingsOptional": "Mapeamentos Padrão (Opcional)", "defaultMappingsOptionalDescription": "Os mapeamentos padrão são usados quando não há uma política de organização definida para uma organização. Pode especificar aqui os mapeamentos padrão de função e organização para recorrer.", "defaultMappingsRole": "Mapeamento de Função Padrão", "defaultMappingsRoleDescription": "JMESPath para extrair informações de função do token ID. O resultado desta expressão deve retornar o nome da função como definido na organização como uma string.", "defaultMappingsOrg": "Mapeamento de Organização Padrão", "defaultMappingsOrgDescription": "JMESPath para extrair informações da organização do token ID. Esta expressão deve retornar o ID da organização ou verdadeiro para que o utilizador tenha permissão para aceder à organização.", "defaultMappingsSubmit": "Guardar Mapeamentos Padrão", "orgPoliciesEdit": "Editar Política da Organização", "org": "Organização", "orgSelect": "Selecionar organização", "orgSearch": "Pesquisar organização", "orgNotFound": "Nenhuma organização encontrada.", "roleMappingPathOptional": "Caminho de Mapeamento de Função (Opcional)", "orgMappingPathOptional": "Caminho de Mapeamento da Organização (Opcional)", "orgPolicyUpdate": "Atualizar Política", "orgPolicyAdd": "Adicionar Política", "orgPolicyConfig": "Configurar acesso para uma organização", "idpUpdatedDescription": "Provedor de identidade atualizado com sucesso", "redirectUrl": "URL de Redirecionamento", "orgIdpRedirectUrls": "Redirecionar URLs", "redirectUrlAbout": "Sobre o URL de Redirecionamento", "redirectUrlAboutDescription": "Essa é a URL para a qual os usuários serão redirecionados após a autenticação. Você precisa configurar esta URL nas configurações do provedor de identidade.", "pangolinAuth": "Autenticação - Pangolin", "verificationCodeLengthRequirements": "O seu código de verificação deve ter 8 caracteres.", "errorOccurred": "Ocorreu um erro", "emailErrorVerify": "Falha ao verificar o email:", "emailVerified": "Email verificado com sucesso! Redirecionando...", "verificationCodeErrorResend": "Falha ao reenviar o código de verificação:", "verificationCodeResend": "Código de verificação reenviado", "verificationCodeResendDescription": "Reenviámos um código de verificação para o seu email. Por favor, verifique a sua caixa de entrada.", "emailVerify": "Verificar Email", "emailVerifyDescription": "Insira o código de verificação enviado para o seu email.", "verificationCode": "Código de Verificação", "verificationCodeEmailSent": "Enviámos um código de verificação para o seu email.", "submit": "Submeter", "emailVerifyResendProgress": "A reenviar...", "emailVerifyResend": "Não recebeu um código? Clique aqui para reenviar", "passwordNotMatch": "As palavras-passe não correspondem", "signupError": "Ocorreu um erro durante o registo", "pangolinLogoAlt": "Logótipo Pangolin", "inviteAlready": "Parece que já foi convidado!", "inviteAlreadyDescription": "Para aceitar o convite, deve iniciar sessão ou criar uma conta.", "signupQuestion": "Já tem uma conta?", "login": "Iniciar sessão", "resourceNotFound": "Recurso Não Encontrado", "resourceNotFoundDescription": "O recurso que está a tentar aceder não existe.", "pincodeRequirementsLength": "O PIN deve ter exatamente 6 dígitos", "pincodeRequirementsChars": "O PIN deve conter apenas números", "passwordRequirementsLength": "A palavra-passe deve ter pelo menos 1 caractere", "passwordRequirementsTitle": "Requisitos de senha:", "passwordRequirementLength": "Pelo menos 8 caracteres de comprimento", "passwordRequirementUppercase": "Pelo menos uma letra maiúscula", "passwordRequirementLowercase": "Pelo menos uma letra minúscula", "passwordRequirementNumber": "Pelo menos um número", "passwordRequirementSpecial": "Pelo menos um caractere especial", "passwordRequirementsMet": "✓ Senha atende a todos os requisitos", "passwordStrength": "Força da senha", "passwordStrengthWeak": "Fraca", "passwordStrengthMedium": "Média", "passwordStrengthStrong": "Forte", "passwordRequirements": "Requisitos:", "passwordRequirementLengthText": "8+ caracteres", "passwordRequirementUppercaseText": "Letra maiúscula (A-Z)", "passwordRequirementLowercaseText": "Letra minúscula (a-z)", "passwordRequirementNumberText": "Número (0-9)", "passwordRequirementSpecialText": "Caractere especial (!@#$%...)", "passwordsDoNotMatch": "As palavras-passe não correspondem", "otpEmailRequirementsLength": "O OTP deve ter pelo menos 1 caractere", "otpEmailSent": "OTP Enviado", "otpEmailSentDescription": "Um OTP foi enviado para o seu email", "otpEmailErrorAuthenticate": "Falha na autenticação por email", "pincodeErrorAuthenticate": "Falha na autenticação com PIN", "passwordErrorAuthenticate": "Falha na autenticação com palavra-passe", "poweredBy": "Desenvolvido por", "authenticationRequired": "Autenticação Necessária", "authenticationMethodChoose": "Escolha o seu método preferido para aceder a {name}", "authenticationRequest": "Deve autenticar-se para aceder a {name}", "user": "Utilizador", "pincodeInput": "Código PIN de 6 dígitos", "pincodeSubmit": "Iniciar sessão com PIN", "passwordSubmit": "Iniciar Sessão com Palavra-passe", "otpEmailDescription": "Um código único será enviado para este email.", "otpEmailSend": "Enviar Código Único", "otpEmail": "Palavra-passe Única (OTP)", "otpEmailSubmit": "Submeter OTP", "backToEmail": "Voltar ao Email", "noSupportKey": "O servidor está rodando sem uma chave de suporte. Considere apoiar o projeto!", "accessDenied": "Acesso Negado", "accessDeniedDescription": "Não tem permissão para aceder a este recurso. Se isto for um erro, contacte o administrador.", "accessTokenError": "Erro ao verificar o token de acesso", "accessGranted": "Acesso Concedido", "accessUrlInvalid": "URL de Acesso Inválido", "accessGrantedDescription": "Foi-lhe concedido acesso a este recurso. A redirecionar...", "accessUrlInvalidDescription": "Este URL de acesso partilhado é inválido. Por favor, contacte o proprietário do recurso para obter um novo URL.", "tokenInvalid": "Token inválido", "pincodeInvalid": "Código inválido", "passwordErrorRequestReset": "Falha ao solicitar redefinição:", "passwordErrorReset": "Falha ao redefinir palavra-passe:", "passwordResetSuccess": "Palavra-passe redefinida com sucesso! Voltar ao início de sessão...", "passwordReset": "Redefinir Palavra-passe", "passwordResetDescription": "Siga os passos para redefinir a sua palavra-passe", "passwordResetSent": "Enviaremos um código de redefinição de palavra-passe para este email.", "passwordResetCode": "Código de Redefinição", "passwordResetCodeDescription": "Verifique o seu email para obter o código de redefinição.", "generatePasswordResetCode": "Gerar código de redefinição de senha", "passwordResetCodeGenerated": "Código de redefinição de senha gerado", "passwordResetCodeGeneratedDescription": "Compartilhe este código com o usuário. Eles podem usá-lo para redefinir sua senha.", "passwordResetUrl": "Reset URL", "passwordNew": "Nova Palavra-passe", "passwordNewConfirm": "Confirmar Nova Palavra-passe", "changePassword": "Mudar a senha", "changePasswordDescription": "Atualize a senha da sua conta", "oldPassword": "Palavra-passe Atual", "newPassword": "Nova Palavra-Passe", "confirmNewPassword": "Confirme a Nova Senha", "changePasswordError": "Falha ao alterar a senha", "changePasswordErrorDescription": "Ocorreu um erro ao alterar sua senha", "changePasswordSuccess": "Senha alterada com sucesso", "changePasswordSuccessDescription": "Sua senha foi atualizada com sucesso", "passwordExpiryRequired": "Expiração de senha necessária", "passwordExpiryDescription": "Esta organização exige que você altere sua senha a cada {maxDays} dias.", "changePasswordNow": "Alterar a senha agora", "pincodeAuth": "Código do Autenticador", "pincodeSubmit2": "Enviar código", "passwordResetSubmit": "Solicitar Redefinição", "passwordResetAlreadyHaveCode": "Inserir Código", "passwordResetSmtpRequired": "Por favor, contate o administrador", "passwordResetSmtpRequiredDescription": "É necessário um código de redefinição de senha para redefinir sua senha. Por favor, contate o administrador para assistência.", "passwordBack": "Voltar à Palavra-passe", "loginBack": "Voltar para a página principal de acesso", "signup": "Registar", "loginStart": "Inicie sessão para começar", "idpOidcTokenValidating": "A validar token OIDC", "idpOidcTokenResponse": "Validar resposta do token OIDC", "idpErrorOidcTokenValidating": "Erro ao validar token OIDC", "idpConnectingTo": "A ligar a {name}", "idpConnectingToDescription": "A validar a sua identidade", "idpConnectingToProcess": "A conectar...", "idpConnectingToFinished": "Conectado", "idpErrorConnectingTo": "Ocorreu um problema ao ligar a {name}. Por favor, contacte o seu administrador.", "idpErrorNotFound": "IdP não encontrado", "inviteInvalid": "Convite Inválido", "inviteInvalidDescription": "O link do convite é inválido.", "inviteErrorWrongUser": "O convite não é para este utilizador", "inviteErrorUserNotExists": "O utilizador não existe. Por favor, crie uma conta primeiro.", "inviteErrorLoginRequired": "Você deve estar logado para aceitar um convite", "inviteErrorExpired": "O convite pode ter expirado", "inviteErrorRevoked": "O convite pode ter sido revogado", "inviteErrorTypo": "Pode haver um erro de digitação no link do convite", "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.", "overview": "Visão Geral", "home": "Início", "settings": "Configurações", "usersAll": "Todos os Utilizadores", "license": "Licença", "pangolinDashboard": "Painel - Pangolin", "noResults": "Nenhum resultado encontrado.", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "Tags Inseridas", "tagsEnteredDescription": "Estas são as tags que você inseriu.", "tagsWarnCannotBeLessThanZero": "maxTags e minTags não podem ser menores que 0", "tagsWarnNotAllowedAutocompleteOptions": "Tag não permitida conforme as opções de autocompletar", "tagsWarnInvalid": "Tag inválida conforme validateTag", "tagWarnTooShort": "A tag {tagText} é muito curta", "tagWarnTooLong": "A tag {tagText} é muito longa", "tagsWarnReachedMaxNumber": "Atingido o número máximo de tags permitidas", "tagWarnDuplicate": "Tag duplicada {tagText} não adicionada", "supportKeyInvalid": "Chave Inválida", "supportKeyInvalidDescription": "A sua chave de suporte é inválida.", "supportKeyValid": "Chave Válida", "supportKeyValidDescription": "A sua chave de suporte foi validada. Obrigado pelo seu apoio!", "supportKeyErrorValidationDescription": "Falha ao validar a chave de suporte.", "supportKey": "Apoie o Desenvolvimento e Adote um Pangolim!", "supportKeyDescription": "Compre uma chave de suporte para nos ajudar a continuar desenvolvendo o Pangolin para a comunidade. A sua contribuição permite-nos dedicar mais tempo para manter e adicionar novos recursos à aplicação para todos. Nunca usaremos isto para restringir recursos. Isto é separado de qualquer Edição Comercial.", "supportKeyPet": "Também poderá adotar e conhecer o seu próprio Pangolim de estimação!", "supportKeyPurchase": "Os pagamentos são processados via GitHub. Depois, pode obter a sua chave em", "supportKeyPurchaseLink": "nosso site", "supportKeyPurchase2": "e resgatá-la aqui.", "supportKeyLearnMore": "Saiba mais.", "supportKeyOptions": "Por favor, selecione a opção que melhor se adequa a si.", "supportKetOptionFull": "Apoiante Completo", "forWholeServer": "Para todo o servidor", "lifetimePurchase": "Compra vitalícia", "supporterStatus": "Estado de apoiante", "buy": "Comprar", "supportKeyOptionLimited": "Apoiante Limitado", "forFiveUsers": "Para 5 ou menos utilizadores", "supportKeyRedeem": "Resgatar Chave de Apoiante", "supportKeyHideSevenDays": "Ocultar por 7 dias", "supportKeyEnter": "Inserir Chave de Apoiante", "supportKeyEnterDescription": "Conheça o seu próprio Pangolim de estimação!", "githubUsername": "Nome de Utilizador GitHub", "supportKeyInput": "Chave de Apoiante", "supportKeyBuy": "Comprar Chave de Apoiante", "logoutError": "Erro ao terminar sessão", "signingAs": "Sessão iniciada como", "serverAdmin": "Administrador do Servidor", "managedSelfhosted": "Gerenciado Auto-Hospedado", "otpEnable": "Ativar Autenticação de Dois Fatores", "otpDisable": "Desativar Autenticação de Dois Fatores", "logout": "Terminar Sessão", "licenseTierProfessionalRequired": "Edição Profissional Necessária", "licenseTierProfessionalRequiredDescription": "Esta funcionalidade só está disponível na Edição Profissional.", "actionGetOrg": "Obter Organização", "updateOrgUser": "Atualizar utilizador Org", "createOrgUser": "Criar utilizador Org", "actionUpdateOrg": "Atualizar Organização", "actionRemoveInvitation": "Remover Convite", "actionUpdateUser": "Atualizar Usuário", "actionGetUser": "Obter Usuário", "actionGetOrgUser": "Obter Utilizador da Organização", "actionListOrgDomains": "Listar Domínios da Organização", "actionGetDomain": "Obter domínio", "actionCreateOrgDomain": "Criar domínio", "actionUpdateOrgDomain": "Atualizar domínio", "actionDeleteOrgDomain": "Excluir domínio", "actionGetDNSRecords": "Obter registros de DNS", "actionRestartOrgDomain": "Reiniciar domínio", "actionCreateSite": "Criar Site", "actionDeleteSite": "Eliminar Site", "actionGetSite": "Obter Site", "actionListSites": "Listar Sites", "actionApplyBlueprint": "Aplicar Diagrama", "actionListBlueprints": "Listar Modelos", "actionGetBlueprint": "Obter Modelo", "setupToken": "Configuração do Token", "setupTokenDescription": "Digite o token de configuração do console do servidor.", "setupTokenRequired": "Token de configuração é necessário", "actionUpdateSite": "Atualizar Site", "actionListSiteRoles": "Listar Funções Permitidas do Site", "actionCreateResource": "Criar Recurso", "actionDeleteResource": "Eliminar Recurso", "actionGetResource": "Obter Recurso", "actionListResource": "Listar Recursos", "actionUpdateResource": "Atualizar Recurso", "actionListResourceUsers": "Listar Utilizadores do Recurso", "actionSetResourceUsers": "Definir Utilizadores do Recurso", "actionSetAllowedResourceRoles": "Definir Funções Permitidas do Recurso", "actionListAllowedResourceRoles": "Listar Funções Permitidas do Recurso", "actionSetResourcePassword": "Definir Palavra-passe do Recurso", "actionSetResourcePincode": "Definir Código PIN do Recurso", "actionSetResourceEmailWhitelist": "Definir Lista Permitida de Emails do Recurso", "actionGetResourceEmailWhitelist": "Obter Lista Permitida de Emails do Recurso", "actionCreateTarget": "Criar Alvo", "actionDeleteTarget": "Eliminar Alvo", "actionGetTarget": "Obter Alvo", "actionListTargets": "Listar Alvos", "actionUpdateTarget": "Atualizar Alvo", "actionCreateRole": "Criar Função", "actionDeleteRole": "Eliminar Função", "actionGetRole": "Obter Função", "actionListRole": "Listar Funções", "actionUpdateRole": "Atualizar Função", "actionListAllowedRoleResources": "Listar Recursos Permitidos da Função", "actionInviteUser": "Convidar Utilizador", "actionRemoveUser": "Remover Utilizador", "actionListUsers": "Listar Utilizadores", "actionAddUserRole": "Adicionar Função ao Utilizador", "actionGenerateAccessToken": "Gerar Token de Acesso", "actionDeleteAccessToken": "Eliminar Token de Acesso", "actionListAccessTokens": "Listar Tokens de Acesso", "actionCreateResourceRule": "Criar Regra de Recurso", "actionDeleteResourceRule": "Eliminar Regra de Recurso", "actionListResourceRules": "Listar Regras de Recurso", "actionUpdateResourceRule": "Atualizar Regra de Recurso", "actionListOrgs": "Listar Organizações", "actionCheckOrgId": "Verificar ID", "actionCreateOrg": "Criar Organização", "actionDeleteOrg": "Eliminar Organização", "actionListApiKeys": "Listar Chaves API", "actionListApiKeyActions": "Listar Ações da Chave API", "actionSetApiKeyActions": "Definir Ações Permitidas da Chave API", "actionCreateApiKey": "Criar Chave API", "actionDeleteApiKey": "Eliminar Chave API", "actionCreateIdp": "Criar IDP", "actionUpdateIdp": "Atualizar IDP", "actionDeleteIdp": "Eliminar IDP", "actionListIdps": "Listar IDP", "actionGetIdp": "Obter IDP", "actionCreateIdpOrg": "Criar Política de Organização IDP", "actionDeleteIdpOrg": "Eliminar Política de Organização IDP", "actionListIdpOrgs": "Listar Organizações IDP", "actionUpdateIdpOrg": "Atualizar Organização IDP", "actionCreateClient": "Criar Cliente", "actionDeleteClient": "Excluir Cliente", "actionArchiveClient": "Arquivar Cliente", "actionUnarchiveClient": "Desarquivar Cliente", "actionBlockClient": "Bloco do Cliente", "actionUnblockClient": "Desbloquear Cliente", "actionUpdateClient": "Atualizar Cliente", "actionListClients": "Listar Clientes", "actionGetClient": "Obter Cliente", "actionCreateSiteResource": "Criar Recurso do Site", "actionDeleteSiteResource": "Eliminar Recurso do Site", "actionGetSiteResource": "Obter Recurso do Site", "actionListSiteResources": "Listar Recursos do Site", "actionUpdateSiteResource": "Atualizar Recurso do Site", "actionListInvitations": "Listar Convites", "actionExportLogs": "Exportar logs", "actionViewLogs": "Visualizar registros", "noneSelected": "Nenhum selecionado", "orgNotFound2": "Nenhuma organização encontrada.", "searchPlaceholder": "Buscar...", "emptySearchOptions": "Nenhuma opção encontrada", "create": "Criar", "orgs": "Organizações", "loginError": "Ocorreu um erro inesperado. Por favor, tente novamente.", "loginRequiredForDevice": "O login é necessário para seu dispositivo.", "passwordForgot": "Esqueceu a sua palavra-passe?", "otpAuth": "Autenticação de Dois Fatores", "otpAuthDescription": "Insira o código da sua aplicação de autenticação ou um dos seus códigos de backup de uso único.", "otpAuthSubmit": "Submeter Código", "idpContinue": "Ou continuar com", "otpAuthBack": "Voltar à Palavra-passe", "navbar": "Menu de Navegação", "navbarDescription": "Menu de navegação principal da aplicação", "navbarDocsLink": "Documentação", "otpErrorEnable": "Não foi possível ativar 2FA", "otpErrorEnableDescription": "Ocorreu um erro ao ativar 2FA", "otpSetupCheckCode": "Por favor, insira um código de 6 dígitos", "otpSetupCheckCodeRetry": "Código inválido. Por favor, tente novamente.", "otpSetup": "Ativar Autenticação de Dois Fatores", "otpSetupDescription": "Proteja a sua conta com uma camada extra de proteção", "otpSetupScanQr": "Digitalize este código QR com a sua aplicação de autenticação ou insira a chave secreta manualmente:", "otpSetupSecretCode": "Código de Autenticação", "otpSetupSuccess": "Autenticação de Dois Fatores Ativada", "otpSetupSuccessStoreBackupCodes": "A sua conta está agora mais segura. Não se esqueça de guardar os seus códigos de backup.", "otpErrorDisable": "Não foi possível desativar 2FA", "otpErrorDisableDescription": "Ocorreu um erro ao desativar 2FA", "otpRemove": "Desativar Autenticação de Dois Fatores", "otpRemoveDescription": "Desativar a autenticação de dois fatores para a sua conta", "otpRemoveSuccess": "Autenticação de Dois Fatores Desativada", "otpRemoveSuccessMessage": "A autenticação de dois fatores foi desativada para a sua conta. Pode ativá-la novamente a qualquer momento.", "otpRemoveSubmit": "Desativar 2FA", "paginator": "Página {current} de {last}", "paginatorToFirst": "Ir para a primeira página", "paginatorToPrevious": "Ir para a página anterior", "paginatorToNext": "Ir para a próxima página", "paginatorToLast": "Ir para a última página", "copyText": "Copiar texto", "copyTextFailed": "Falha ao copiar texto: ", "copyTextClipboard": "Copiar para a área de transferência", "inviteErrorInvalidConfirmation": "Confirmação inválida", "passwordRequired": "A senha é obrigatória", "allowAll": "Permitir todos", "permissionsAllowAll": "Permitir Todas as Permissões", "githubUsernameRequired": "O nome de utilizador GitHub é obrigatório", "supportKeyRequired": "A chave de apoiante é obrigatória", "passwordRequirementsChars": "A palavra-passe deve ter pelo menos 8 caracteres", "language": "Idioma", "verificationCodeRequired": "O código é obrigatório", "userErrorNoUpdate": "Não existe utilizador para atualizar", "siteErrorNoUpdate": "Não existe site para atualizar", "resourceErrorNoUpdate": "Não existe recurso para atualizar", "authErrorNoUpdate": "Não existem informações de autenticação para atualizar", "orgErrorNoUpdate": "Não existe organização para atualizar", "orgErrorNoProvided": "Nenhuma organização fornecida", "apiKeysErrorNoUpdate": "Não existe chave API para atualizar", "sidebarOverview": "Geral", "sidebarHome": "Residencial", "sidebarSites": "sites", "sidebarApprovals": "Solicitações de aprovação", "sidebarResources": "Recursos", "sidebarProxyResources": "Público", "sidebarClientResources": "Privado", "sidebarAccessControl": "Controle de Acesso", "sidebarLogsAndAnalytics": "Registros e Análises", "sidebarTeam": "Equipe", "sidebarUsers": "Utilizadores", "sidebarAdmin": "Administrador", "sidebarInvitations": "Convites", "sidebarRoles": "Papéis", "sidebarShareableLinks": "Links", "sidebarApiKeys": "Chaves API", "sidebarSettings": "Configurações", "sidebarAllUsers": "Todos os utilizadores", "sidebarIdentityProviders": "Provedores de identidade", "sidebarLicense": "Tipo:", "sidebarClients": "Clientes", "sidebarUserDevices": "Dispositivos do usuário", "sidebarMachineClients": "Máquinas", "sidebarDomains": "Domínios", "sidebarGeneral": "Gerir", "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", "blueprintAdd": "Adicionar Diagrama", "blueprintGoBack": "Ver todos os Diagramas", "blueprintCreate": "Criar Diagrama", "blueprintCreateDescription2": "Siga as etapas abaixo para criar e aplicar um novo diagrama", "blueprintDetails": "Detalhes do Diagrama", "blueprintDetailsDescription": "Veja o resultado do diagrama aplicado e todos os erros que ocorreram", "blueprintInfo": "Informação do Diagrama", "message": "mensagem", "blueprintContentsDescription": "Definir o conteúdo YAML descrevendo a infraestrutura", "blueprintErrorCreateDescription": "Ocorreu um erro ao aplicar o diagrama", "blueprintErrorCreate": "Erro ao criar diagrama", "searchBlueprintProgress": "Pesquisar diagramas...", "appliedAt": "Aplicado em", "source": "fonte", "contents": "Conteúdo", "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.", "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.", "containerName": "Nome:", "containerImage": "Imagem:", "containerState": "Estado:", "containerNetworks": "Redes", "containerHostnameIp": "Hostname/IP", "containerLabels": "Marcadores", "containerLabelsCount": "{count, plural, one {# rótulo} other {# rótulos}}", "containerLabelsTitle": "Etiquetas do Contêiner", "containerLabelEmpty": "", "containerPorts": "Portas", "containerPortsMore": "+ Mais{count}", "containerActions": "Ações.", "select": "Selecionar", "noContainersMatchingFilters": "Nenhum contêiner encontrado corresponde aos filtros atuais.", "showContainersWithoutPorts": "Mostrar contêineres sem portas", "showStoppedContainers": "Mostrar contêineres parados", "noContainersFound": "Nenhum contêiner encontrado. Certifique-se de que os contêineres Docker estão em execução.", "searchContainersPlaceholder": "Pesquisar entre os contêineres {count}...", "searchResultsCount": "{count, plural, one {# resultado} other {# resultados}}", "filters": "Filtros", "filterOptions": "Opções de Filtro", "filterPorts": "Portas", "filterStopped": "Parado", "clearAllFilters": "Limpar todos os filtros", "columns": "Colunas", "toggleColumns": "Alternar Colunas", "refreshContainersList": "Atualizar lista de contêineres", "searching": "Buscando...", "noContainersFoundMatching": "Nenhum recipiente encontrado \"{filter}\".", "light": "claro", "dark": "escuro", "system": "sistema", "theme": "Tema", "subnetRequired": "Sub-rede é obrigatória", "initialSetupTitle": "Configuração Inicial do Servidor", "initialSetupDescription": "Crie a conta de administrador inicial do servidor. Apenas um administrador do servidor pode existir. Você sempre pode alterar essas credenciais posteriormente.", "createAdminAccount": "Criar Conta de Administrador", "setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.", "certificateStatus": "Status do Certificado", "loading": "Carregando", "loadingAnalytics": "Carregando Analytics", "restart": "Reiniciar", "domains": "Domínios", "domainsDescription": "Criar e gerenciar domínios disponíveis na organização", "domainsSearch": "Pesquisar domínios...", "domainAdd": "Adicionar Domínio", "domainAddDescription": "Registrar um novo domínio com a organização", "domainCreate": "Criar Domínio", "domainCreatedDescription": "Domínio criado com sucesso", "domainDeletedDescription": "Domínio deletado com sucesso", "domainQuestionRemove": "Tem certeza de que deseja excluir o domínio?", "domainMessageRemove": "Uma vez removido, o domínio não será mais associado à organização.", "domainConfirmDelete": "Confirmar Exclusão de Domínio", "domainDelete": "Excluir Domínio", "domain": "Domínio", "selectDomainTypeNsName": "Delegação de Domínio (NS)", "selectDomainTypeNsDescription": "Este domínio e todos os seus subdomínios. Use isso quando quiser controlar uma zona de domínio inteira.", "selectDomainTypeCnameName": "Domínio Único (CNAME)", "selectDomainTypeCnameDescription": "Apenas este domínio específico. Use isso para subdomínios individuais ou entradas de domínio específicas.", "selectDomainTypeWildcardName": "Domínio Coringa", "selectDomainTypeWildcardDescription": "Este domínio e seus subdomínios.", "domainDelegation": "Domínio Único", "selectType": "Selecione um tipo", "actions": "Ações", "refresh": "Atualizar", "refreshError": "Falha ao atualizar dados", "verified": "Verificado", "pending": "Pendente", "pendingApproval": "Aprovação pendente", "sidebarBilling": "Faturamento", "billing": "Faturamento", "orgBillingDescription": "Gerenciar informações e assinaturas de cobrança", "github": "GitHub", "pangolinHosted": "Hospedagem Pangolin", "fossorial": "Fossorial", "completeAccountSetup": "Completar Configuração da Conta", "completeAccountSetupDescription": "Defina sua senha para começar", "accountSetupSent": "Enviaremos um código de ativação da conta para este endereço de e-mail.", "accountSetupCode": "Código de Ativação", "accountSetupCodeDescription": "Verifique seu e-mail para obter o código de ativação.", "passwordCreate": "Criar Senha", "passwordCreateConfirm": "Confirmar Senha", "accountSetupSubmit": "Enviar Código de Ativação", "completeSetup": "Configuração Completa", "accountSetupSuccess": "Configuração da conta concluída! Bem-vindo ao Pangolin!", "documentation": "Documentação", "saveAllSettings": "Guardar Todas as Configurações", "saveResourceTargets": "Guardar Alvos", "saveResourceHttp": "Guardar Configurações de Proxy", "saveProxyProtocol": "Salvar configurações do protocolo de proxy", "settingsUpdated": "Configurações atualizadas", "settingsUpdatedDescription": "Configurações atualizadas com sucesso", "settingsErrorUpdate": "Falha ao atualizar configurações", "settingsErrorUpdateDescription": "Ocorreu um erro ao atualizar configurações", "sidebarCollapse": "Recolher", "sidebarExpand": "Expandir", "productUpdateMoreInfo": "Mais {noOfUpdates} atualizações", "productUpdateInfo": "Atualizações {noOfUpdates}", "productUpdateWhatsNew": "Novidades", "productUpdateTitle": "Atualizações de Produto", "productUpdateEmpty": "Não há atualizações", "dismissAll": "Recusar tudo", "pangolinUpdateAvailable": "Atualização disponível", "pangolinUpdateAvailableInfo": "A versão {version} está pronta para ser instalada", "pangolinUpdateAvailableReleaseNotes": "Ver notas de versão", "newtUpdateAvailable": "Nova Atualização Disponível", "newtUpdateAvailableInfo": "Uma nova versão do Newt está disponível. Atualize para a versão mais recente para uma melhor experiência.", "domainPickerEnterDomain": "Domínio", "domainPickerPlaceholder": "myapp.exemplo.com", "domainPickerDescription": "Insira o domínio completo do recurso para ver as opções disponíveis.", "domainPickerDescriptionSaas": "Insira um domínio completo, subdomínio ou apenas um nome para ver as opções disponíveis", "domainPickerTabAll": "Todos", "domainPickerTabOrganization": "Organização", "domainPickerTabProvided": "Fornecido", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Verificando disponibilidade...", "domainPickerNoMatchingDomains": "Nenhum domínio correspondente encontrado. Tente um domínio diferente ou verifique as configurações do domínio da organização.", "domainPickerOrganizationDomains": "Domínios da Organização", "domainPickerProvidedDomains": "Domínios Fornecidos", "domainPickerSubdomain": "Subdomínio: {subdomain}", "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mostrar Mais", "regionSelectorTitle": "Selecionar Região", "regionSelectorInfo": "Selecionar uma região nos ajuda a fornecer melhor desempenho para sua localização. Você não precisa estar na mesma região que seu servidor.", "regionSelectorPlaceholder": "Escolher uma região", "regionSelectorComingSoon": "Em breve", "billingLoadingSubscription": "Carregando assinatura...", "billingFreeTier": "Plano Gratuito", "billingWarningOverLimit": "Aviso: Você ultrapassou um ou mais limites de uso. Seus sites não se conectarão até você modificar sua assinatura ou ajustar seu uso.", "billingUsageLimitsOverview": "Visão Geral dos Limites de Uso", "billingMonitorUsage": "Monitore seu uso em relação aos limites configurados. Se precisar aumentar esses limites, entre em contato conosco support@pangolin.net.", "billingDataUsage": "Uso de Dados", "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", "billingIncludedUsage": "Uso Incluído", "billingIncludedUsageDescription": "Uso incluído no seu plano de assinatura atual", "billingFreeTierIncludedUsage": "Limites de uso do plano gratuito", "billingIncluded": "incluído", "billingEstimatedTotal": "Total Estimado:", "billingNotes": "Notas", "billingEstimateNote": "Esta é uma estimativa baseada no seu uso atual.", "billingActualChargesMayVary": "As cobranças reais podem variar.", "billingBilledAtEnd": "Sua cobrança será feita ao final do período de cobrança.", "billingModifySubscription": "Modificar Assinatura", "billingStartSubscription": "Iniciar Assinatura", "billingRecurringCharge": "Cobrança Recorrente", "billingManageSubscriptionSettings": "Gerenciar configurações de assinatura e preferências", "billingNoActiveSubscription": "Você não tem uma assinatura ativa. Inicie sua assinatura para aumentar os limites de uso.", "billingFailedToLoadSubscription": "Falha ao carregar assinatura", "billingFailedToLoadUsage": "Falha ao carregar uso", "billingFailedToGetCheckoutUrl": "Falha ao obter URL de checkout", "billingPleaseTryAgainLater": "Por favor, tente novamente mais tarde.", "billingCheckoutError": "Erro de Checkout", "billingFailedToGetPortalUrl": "Falha ao obter URL do portal", "billingPortalError": "Erro do Portal", "billingDataUsageInfo": "Você é cobrado por todos os dados transferidos através de seus túneis seguros quando conectado à nuvem. Isso inclui o tráfego de entrada e saída em todos os seus sites. Quando você atingir o seu limite, seus sites desconectarão até que você atualize seu plano ou reduza o uso. Os dados não serão cobrados ao usar os nós.", "billingSInfo": "Quantos sites você pode usar", "billingUsersInfo": "Quantos usuários você pode usar", "billingDomainInfo": "Quantos domínios você pode usar", "billingRemoteExitNodesInfo": "Quantos nós remotos você pode usar", "billingLicenseKeys": "Chaves de Licença", "billingLicenseKeysDescription": "Gerenciar suas subscrições de chave de licença", "billingLicenseSubscription": "Assinatura de Licença", "billingInactive": "Inativo", "billingLicenseItem": "Item de Licença", "billingQuantity": "Quantidade", "billingTotal": "total:", "billingModifyLicenses": "Modificar assinatura de licença", "domainNotFound": "Domínio Não Encontrado", "domainNotFoundDescription": "Este recurso está desativado porque o domínio não existe mais em nosso sistema. Defina um novo domínio para este recurso.", "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", "securityKeyRegister": "Registrar nova chave de segurança", "securityKeyList": "Suas chaves de segurança", "securityKeyNone": "Nenhuma chave de segurança registrada", "securityKeyNameRequired": "Nome é obrigatório", "securityKeyRemove": "Remover", "securityKeyLastUsed": "Último uso: {date}", "securityKeyNameLabel": "Nome", "securityKeyRegisterSuccess": "Chave de segurança registrada com sucesso", "securityKeyRegisterError": "Erro ao registrar chave de segurança", "securityKeyRemoveSuccess": "Chave de segurança removida com sucesso", "securityKeyRemoveError": "Erro ao remover chave de segurança", "securityKeyLoadError": "Erro ao carregar chaves de segurança", "securityKeyLogin": "Usar chave de segurança", "securityKeyAuthError": "Erro ao autenticar com chave de segurança", "securityKeyRecommendation": "Considere registrar outra chave de segurança em um dispositivo diferente para garantir que você não fique bloqueado da sua conta.", "registering": "Registrando...", "securityKeyPrompt": "Verifique sua identidade usando sua chave de segurança. Certifique-se de que sua chave de segurança está conectada e pronta.", "securityKeyBrowserNotSupported": "Seu navegador não suporta chaves de segurança. Use um navegador moderno como Chrome, Firefox ou Safari.", "securityKeyPermissionDenied": "Permita o acesso à sua chave de segurança para continuar o login.", "securityKeyRemovedTooQuickly": "Mantenha sua chave de segurança conectada até que o processo de login seja concluído.", "securityKeyNotSupported": "Sua chave de segurança pode não ser compatível. Tente uma chave de segurança diferente.", "securityKeyUnknownError": "Houve um problema ao usar sua chave de segurança. Tente novamente.", "twoFactorRequired": "A autenticação de dois fatores é necessária para registrar uma chave de segurança.", "twoFactor": "Autenticação de Dois Fatores", "twoFactorAuthentication": "Autenticação dupla", "twoFactorDescription": "Esta organização requer autenticação de dois fatores.", "enableTwoFactor": "Ativar autenticação dupla", "organizationSecurityPolicy": "Política de Segurança da Organização", "organizationSecurityPolicyDescription": "Esta organização tem requisitos de segurança que precisam ser cumpridos antes que você possa acessá-la", "securityRequirements": "Requisitos De Segurança", "allRequirementsMet": "Todos os requisitos foram cumpridos", "completeRequirementsToContinue": "Preencha os requisitos abaixo para continuar acessando esta organização", "youCanNowAccessOrganization": "Agora você pode acessar esta organização", "reauthenticationRequired": "Comprimento da sessão", "reauthenticationDescription": "Esta organização requer que você faça login a cada {maxDays} dias.", "reauthenticationDescriptionHours": "Esta organização exige que você faça login a cada {maxHours} horas.", "reauthenticateNow": "Iniciar sessão novamente", "adminEnabled2FaOnYourAccount": "Seu administrador ativou a autenticação de dois fatores para {email}. Complete o processo de configuração para continuar.", "securityKeyAdd": "Adicionar Chave de Segurança", "securityKeyRegisterTitle": "Registrar Nova Chave de Segurança", "securityKeyRegisterDescription": "Conecte sua chave de segurança e insira um nome para identificá-la", "securityKeyTwoFactorRequired": "Autenticação de Dois Fatores Obrigatória", "securityKeyTwoFactorDescription": "Insira seu código de autenticação de dois fatores para registrar a chave de segurança", "securityKeyTwoFactorRemoveDescription": "Insira seu código de autenticação de dois fatores para remover a chave de segurança", "securityKeyTwoFactorCode": "Código de Dois Fatores", "securityKeyRemoveTitle": "Remover Chave de Segurança", "securityKeyRemoveDescription": "Insira sua senha para remover a chave de segurança \"{name}\"", "securityKeyNoKeysRegistered": "Nenhuma chave de segurança registrada", "securityKeyNoKeysDescription": "Adicione uma chave de segurança para melhorar a segurança da sua conta", "createDomainRequired": "Domínio é obrigatório", "createDomainAddDnsRecords": "Adicionar Registros DNS", "createDomainAddDnsRecordsDescription": "Adicione os seguintes registros DNS ao seu provedor de domínio para completar a configuração.", "createDomainNsRecords": "Registros NS", "createDomainRecord": "Registrar", "createDomainType": "Tipo:", "createDomainName": "Nome:", "createDomainValue": "Valor:", "createDomainCnameRecords": "Registros CNAME", "createDomainARecords": "Registros A", "createDomainRecordNumber": "Registrar {number}", "createDomainTxtRecords": "Registros TXT", "createDomainSaveTheseRecords": "Guardar Esses Registros", "createDomainSaveTheseRecordsDescription": "Certifique-se de salvar esses registros DNS, pois você não os verá novamente.", "createDomainDnsPropagation": "Propagação DNS", "createDomainDnsPropagationDescription": "Alterações no DNS podem levar algum tempo para se propagar pela internet. Pode levar de alguns minutos a 48 horas, dependendo do seu provedor de DNS e das configurações de TTL.", "resourcePortRequired": "Número da porta é obrigatório para recursos não-HTTP", "resourcePortNotAllowed": "Número da porta não deve ser definido para recursos HTTP", "billingPricingCalculatorLink": "Calculadora de Preços", "billingYourPlan": "Seu plano", "billingViewOrModifyPlan": "Ver ou modificar seu plano atual", "billingViewPlanDetails": "Ver detalhes do plano", "billingUsageAndLimits": "Uso e Limites", "billingViewUsageAndLimits": "Ver os limites do seu plano e o uso atual", "billingCurrentUsage": "Uso atual", "billingMaximumLimits": "Limite Máximo", "billingRemoteNodes": "Nós remotos", "billingUnlimited": "Ilimitado", "billingPaidLicenseKeys": "Chaves de licença paga", "billingManageLicenseSubscription": "Gerencie sua assinatura para as chaves de licenças auto-hospedadas pagas", "billingCurrentKeys": "Chaves atuais", "billingModifyCurrentPlan": "Modificar o Plano Atual", "billingConfirmUpgrade": "Confirmar a atualização", "billingConfirmDowngrade": "Confirmar downgrade", "billingConfirmUpgradeDescription": "Você está prestes a atualizar seu plano. Revise os novos limites e preços abaixo.", "billingConfirmDowngradeDescription": "Você está prestes a fazer o downgrade do seu plano. Revise os novos limites e preços abaixo.", "billingPlanIncludes": "Plano Inclui", "billingProcessing": "Processandochar@@0", "billingConfirmUpgradeButton": "Confirmar a atualização", "billingConfirmDowngradeButton": "Confirmar downgrade", "billingLimitViolationWarning": "Uso excede novos limites de plano", "billingLimitViolationDescription": "Seu uso atual excede os limites deste plano. Após desclassificação, todas as ações serão desabilitadas até que você reduza o uso dentro dos novos limites. Por favor, reveja os recursos abaixo que atualmente estão acima dos limites. Limites de violação:", "billingFeatureLossWarning": "Aviso de disponibilidade de recursos", "billingFeatureLossDescription": "Ao fazer o downgrading, recursos não disponíveis no novo plano serão desativados automaticamente. Algumas configurações e configurações podem ser perdidas. Por favor, revise a matriz de preços para entender quais características não estarão mais disponíveis.", "billingUsageExceedsLimit": "Uso atual ({current}) excede o limite ({limit})", "billingPastDueTitle": "Pagamento passado devido", "billingPastDueDescription": "Seu pagamento está vencido. Por favor, atualize seu método de pagamento para continuar usando os recursos do seu plano atual. Se não for resolvido, sua assinatura será cancelada e você será revertido para o nível gratuito.", "billingUnpaidTitle": "Assinatura não paga", "billingUnpaidDescription": "Sua assinatura não foi paga e você voltou para o nível gratuito. Atualize o seu método de pagamento para restaurar sua assinatura.", "billingIncompleteTitle": "Pagamento Incompleto", "billingIncompleteDescription": "Seu pagamento está incompleto. Por favor, complete o processo de pagamento para ativar sua assinatura.", "billingIncompleteExpiredTitle": "Pagamento expirado", "billingIncompleteExpiredDescription": "Seu pagamento nunca foi concluído e expirou. Você foi revertido para o nível gratuito. Por favor, inscreva-se novamente para restaurar o acesso a recursos pagos.", "billingManageSubscription": "Gerencie sua assinatura", "billingResolvePaymentIssue": "Por favor, resolva seu problema de pagamento antes de atualizar ou rebaixar", "signUpTerms": { "IAgreeToThe": "Concordo com", "termsOfService": "os termos de serviço", "and": "e", "privacyPolicy": "política de privacidade." }, "signUpMarketing": { "keepMeInTheLoop": "Mantenha-me à disposição com notícias, atualizações e novos recursos por e-mail." }, "siteRequired": "Site é obrigatório.", "olmTunnel": "Olm Tunnel", "olmTunnelDescription": "Use Olm para conectividade do cliente", "errorCreatingClient": "Erro ao criar cliente", "clientDefaultsNotFound": "Padrões do cliente não encontrados", "createClient": "Criar Cliente", "createClientDescription": "Criar um novo cliente para acessar recursos privados", "seeAllClients": "Ver Todos os Clientes", "clientInformation": "Informações do Cliente", "clientNamePlaceholder": "Nome do cliente", "address": "Endereço", "subnetPlaceholder": "Sub-rede", "addressDescription": "O endereço interno do cliente. Deve estar dentro da sub-rede da organização.", "selectSites": "Selecionar sites", "sitesDescription": "O cliente terá conectividade com os sites selecionados", "clientInstallOlm": "Instalar Olm", "clientInstallOlmDescription": "Execute o Olm em seu sistema", "clientOlmCredentials": "Credenciais", "clientOlmCredentialsDescription": "É assim que o cliente irá se autenticar com o servidor", "olmEndpoint": "Endpoint", "olmId": "ID", "olmSecretKey": "Chave Secreta", "clientCredentialsSave": "Salvar as Credenciais", "clientCredentialsSaveDescription": "Você só poderá ver isto uma vez. Certifique-se de copiá-las para um local seguro.", "generalSettingsDescription": "Configure as configurações gerais para este cliente", "clientUpdated": "Cliente atualizado", "clientUpdatedDescription": "O cliente foi atualizado.", "clientUpdateFailed": "Falha ao atualizar cliente", "clientUpdateError": "Ocorreu um erro ao atualizar o cliente.", "sitesFetchFailed": "Falha ao buscar sites", "sitesFetchError": "Ocorreu um erro ao buscar sites.", "olmErrorFetchReleases": "Ocorreu um erro ao buscar lançamentos do Olm.", "olmErrorFetchLatest": "Ocorreu um erro ao buscar o lançamento mais recente do Olm.", "enterCidrRange": "Insira o intervalo CIDR", "resourceEnableProxy": "Ativar Proxy Público", "resourceEnableProxyDescription": "Permite proxy público para este recurso. Isso permite o acesso ao recurso de fora da rede através da nuvem em uma porta aberta. Requer configuração do Traefik.", "externalProxyEnabled": "Proxy Externo Habilitado", "addNewTarget": "Adicionar Novo Alvo", "targetsList": "Lista de Alvos", "advancedMode": "Modo Avançado", "advancedSettings": "Configurações Avançadas", "targetErrorDuplicateTargetFound": "Alvo duplicado encontrado", "healthCheckHealthy": "Saudável", "healthCheckUnhealthy": "Não Saudável", "healthCheckUnknown": "Desconhecido", "healthCheck": "Verificação de Saúde", "configureHealthCheck": "Configurar Verificação de Saúde", "configureHealthCheckDescription": "Configure a monitorização de saúde para {target}", "enableHealthChecks": "Ativar Verificações de Saúde", "enableHealthChecksDescription": "Monitore a saúde deste alvo. Você pode monitorar um ponto de extremidade diferente do alvo, se necessário.", "healthScheme": "Método", "healthSelectScheme": "Selecione o Método", "healthCheckPortInvalid": "A porta do exame de saúde deve estar entre 1 e 65535", "healthCheckPath": "Caminho", "healthHostname": "IP / Nome do Host", "healthPort": "Porta", "healthCheckPathDescription": "O caminho para verificar o estado de saúde.", "healthyIntervalSeconds": "Intervalo Saudável (seg)", "unhealthyIntervalSeconds": "Intervalo Insalubre (seg)", "IntervalSeconds": "Intervalo Saudável", "timeoutSeconds": "Tempo limite (seg)", "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 separada por vírgulas de comandos que o usuário pode executar com sudo.", "sshCreateHomeDir": "Criar Diretório Inicial", "sshUnixGroups": "Grupos Unix", "sshUnixGroupsDescription": "Grupos Unix separados por vírgulas para adicionar o usuário no host alvo.", "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.", "customHeaders": "Cabeçalhos Personalizados", "customHeadersDescription": "Separados por cabeçalhos da nova linha: Nome do Cabeçalho: valor", "headersValidationError": "Cabeçalhos devem estar no formato: Nome do Cabeçalho: valor.", "saveHealthCheck": "Salvar Verificação de Saúde", "healthCheckSaved": "Verificação de Saúde Salva", "healthCheckSavedDescription": "Configuração de verificação de saúde salva com sucesso", "healthCheckError": "Erro de Verificação de Saúde", "healthCheckErrorDescription": "Ocorreu um erro ao salvar a configuração de verificação de saúde", "healthCheckPathRequired": "O caminho de verificação de saúde é obrigatório", "healthCheckMethodRequired": "O método HTTP é obrigatório", "healthCheckIntervalMin": "O intervalo de verificação deve ser de pelo menos 5 segundos", "healthCheckTimeoutMin": "O tempo limite deve ser de pelo menos 1 segundo", "healthCheckRetryMin": "As tentativas de repetição devem ser pelo menos 1", "httpMethod": "Método HTTP", "selectHttpMethod": "Selecionar método HTTP", "domainPickerSubdomainLabel": "Subdomínio", "domainPickerBaseDomainLabel": "Domínio Base", "domainPickerSearchDomains": "Buscar domínios...", "domainPickerNoDomainsFound": "Nenhum domínio encontrado", "domainPickerLoadingDomains": "Carregando domínios...", "domainPickerSelectBaseDomain": "Selecione o domínio base...", "domainPickerNotAvailableForCname": "Não disponível para domínios CNAME", "domainPickerEnterSubdomainOrLeaveBlank": "Digite um subdomínio ou deixe em branco para usar o domínio base.", "domainPickerEnterSubdomainToSearch": "Digite um subdomínio para buscar e selecionar entre os domínios gratuitos disponíveis.", "domainPickerFreeDomains": "Domínios Gratuitos", "domainPickerSearchForAvailableDomains": "Pesquise por domínios disponíveis", "domainPickerNotWorkSelfHosted": "Nota: Domínios gratuitos fornecidos não estão disponíveis para instâncias auto-hospedadas no momento.", "resourceDomain": "Domínio", "resourceEditDomain": "Editar Domínio", "siteName": "Nome do Site", "proxyPort": "Porta", "resourcesTableProxyResources": "Público", "resourcesTableClientResources": "Privado", "resourcesTableNoProxyResourcesFound": "Nenhum recurso de proxy encontrado.", "resourcesTableNoInternalResourcesFound": "Nenhum recurso interno encontrado.", "resourcesTableDestination": "Destino", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Endereço do Pseudônimo", "resourcesTableAliasAddressInfo": "Este endereço faz parte da sub-rede de utilitários da organização. É usado para resolver registros de alias usando resolução de DNS interno.", "resourcesTableClients": "Clientes", "resourcesTableAndOnlyAccessibleInternally": "e são acessíveis apenas internamente quando conectados com um cliente.", "resourcesTableNoTargets": "Nenhum alvo", "resourcesTableHealthy": "Saudável", "resourcesTableDegraded": "Degradado", "resourcesTableOffline": "Desconectado", "resourcesTableUnknown": "Desconhecido", "resourcesTableNotMonitored": "Não monitorado", "editInternalResourceDialogEditClientResource": "Editar Recurso Privado", "editInternalResourceDialogUpdateResourceProperties": "Atualizar as configurações de recursos e controles de acesso para {resourceName}", "editInternalResourceDialogResourceProperties": "Propriedades do Recurso", "editInternalResourceDialogName": "Nome", "editInternalResourceDialogProtocol": "Protocolo", "editInternalResourceDialogSitePort": "Porta do Site", "editInternalResourceDialogTargetConfiguration": "Configuração do Alvo", "editInternalResourceDialogCancel": "Cancelar", "editInternalResourceDialogSaveResource": "Guardar Recurso", "editInternalResourceDialogSuccess": "Sucesso", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Recurso interno atualizado com sucesso", "editInternalResourceDialogError": "Erro", "editInternalResourceDialogFailedToUpdateInternalResource": "Falha ao atualizar recurso interno", "editInternalResourceDialogNameRequired": "Nome é obrigatório", "editInternalResourceDialogNameMaxLength": "Nome deve ser inferior a 255 caracteres", "editInternalResourceDialogProxyPortMin": "Porta de proxy deve ser pelo menos 1", "editInternalResourceDialogProxyPortMax": "Porta de proxy deve ser inferior a 65536", "editInternalResourceDialogInvalidIPAddressFormat": "Formato de endereço IP inválido", "editInternalResourceDialogDestinationPortMin": "Porta de destino deve ser pelo menos 1", "editInternalResourceDialogDestinationPortMax": "Porta de destino deve ser inferior a 65536", "editInternalResourceDialogPortModeRequired": "Protocolo, porta de proxy e porta de destino são necessários para o modo de porto", "editInternalResourceDialogMode": "Modo", "editInternalResourceDialogModePort": "Porta", "editInternalResourceDialogModeHost": "Servidor", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Destino", "editInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.", "editInternalResourceDialogDestinationIPDescription": "O IP ou endereço do hostname do recurso na rede do site.", "editInternalResourceDialogDestinationCidrDescription": "A faixa CIDR do recurso na rede do site.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Um alias de DNS interno opcional para este recurso.", "createInternalResourceDialogNoSitesAvailable": "Nenhum Site Disponível", "createInternalResourceDialogNoSitesAvailableDescription": "Você precisa ter pelo menos um site Newt com uma sub-rede configurada para criar recursos internos.", "createInternalResourceDialogClose": "Fechar", "createInternalResourceDialogCreateClientResource": "Criar Recurso Privado", "createInternalResourceDialogCreateClientResourceDescription": "Criar um novo recurso que só será acessível para clientes conectados à organização", "createInternalResourceDialogResourceProperties": "Propriedades do Recurso", "createInternalResourceDialogName": "Nome", "createInternalResourceDialogSite": "Site", "selectSite": "Selecionar site...", "noSitesFound": "Nenhum site encontrado.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Porta do Site", "createInternalResourceDialogSitePortDescription": "Use esta porta para aceder o recurso no site quando conectado com um cliente.", "createInternalResourceDialogTargetConfiguration": "Configuração do Alvo", "createInternalResourceDialogDestinationIPDescription": "O IP ou endereço do hostname do recurso na rede do site.", "createInternalResourceDialogDestinationPortDescription": "A porta no IP de destino onde o recurso está acessível.", "createInternalResourceDialogCancel": "Cancelar", "createInternalResourceDialogCreateResource": "Criar Recurso", "createInternalResourceDialogSuccess": "Sucesso", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Recurso interno criado com sucesso", "createInternalResourceDialogError": "Erro", "createInternalResourceDialogFailedToCreateInternalResource": "Falha ao criar recurso interno", "createInternalResourceDialogNameRequired": "Nome é obrigatório", "createInternalResourceDialogNameMaxLength": "Nome deve ser inferior a 255 caracteres", "createInternalResourceDialogPleaseSelectSite": "Por favor, selecione um site", "createInternalResourceDialogProxyPortMin": "Porta de proxy deve ser pelo menos 1", "createInternalResourceDialogProxyPortMax": "Porta de proxy deve ser inferior a 65536", "createInternalResourceDialogInvalidIPAddressFormat": "Formato de endereço IP inválido", "createInternalResourceDialogDestinationPortMin": "Porta de destino deve ser pelo menos 1", "createInternalResourceDialogDestinationPortMax": "Porta de destino deve ser inferior a 65536", "createInternalResourceDialogPortModeRequired": "Protocolo, porta de proxy e porta de destino são necessários para o modo de porto", "createInternalResourceDialogMode": "Modo", "createInternalResourceDialogModePort": "Porta", "createInternalResourceDialogModeHost": "Servidor", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Destino", "createInternalResourceDialogDestinationHostDescription": "O endereço IP ou o nome do host do recurso na rede do site.", "createInternalResourceDialogDestinationCidrDescription": "A faixa CIDR do recurso na rede do site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Um alias de DNS interno opcional para este recurso.", "siteConfiguration": "Configuração", "siteAcceptClientConnections": "Aceitar Conexões de Clientes", "siteAcceptClientConnectionsDescription": "Permitir que dispositivos de usuário e clientes acessem recursos neste site. Isso pode ser alterado mais tarde.", "siteAddress": "Endereço do Site (Avançado)", "siteAddressDescription": "Endereço interno do site. Deve estar dentro da sub-rede da organização.", "siteNameDescription": "O nome de exibição do site que pode ser alterado mais tarde.", "autoLoginExternalIdp": "Login Automático com IDP Externo", "autoLoginExternalIdpDescription": "Redirecionar imediatamente o usuário para o provedor de identidade externo para autenticação.", "selectIdp": "Selecionar IDP", "selectIdpPlaceholder": "Escolher um IDP...", "selectIdpRequired": "Por favor, selecione um IDP quando o login automático estiver ativado.", "autoLoginTitle": "Redirecionando", "autoLoginDescription": "Redirecionando você para o provedor de identidade externo para autenticação.", "autoLoginProcessing": "Preparando autenticação...", "autoLoginRedirecting": "Redirecionando para login...", "autoLoginError": "Erro de Login Automático", "autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.", "autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação.", "remoteExitNodeManageRemoteExitNodes": "Nós remotos", "remoteExitNodeDescription": "Hospede seus próprios nós de retransmissão e proxy servidores remotamente", "remoteExitNodes": "Nós", "searchRemoteExitNodes": "Buscar nós...", "remoteExitNodeAdd": "Adicionar node", "remoteExitNodeErrorDelete": "Erro ao excluir nó", "remoteExitNodeQuestionRemove": "Tem certeza de que deseja remover o nó da organização?", "remoteExitNodeMessageRemove": "Uma vez removido, o nó não estará mais acessível.", "remoteExitNodeConfirmDelete": "Confirmar exclusão do nó", "remoteExitNodeDelete": "Excluir nó", "sidebarRemoteExitNodes": "Nós remotos", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Chave Secreta", "remoteExitNodeCreate": { "title": "Criar Nó Remoto", "description": "Crie um novo nó de retransmissão e proxy servidor auto-hospedado", "viewAllButton": "Ver Todos os Nós", "strategy": { "title": "Estratégia de Criação", "description": "Selecione como você deseja criar o nó remoto", "adopt": { "title": "Adotar Nodo", "description": "Escolha isto se você já tem credenciais para o nó." }, "generate": { "title": "Gerar Chaves", "description": "Escolha esta opção se você quer gerar novas chaves para o nó." } }, "adopt": { "title": "Adotar Nodo Existente", "description": "Digite as credenciais do nó existente que deseja adoptar", "nodeIdLabel": "Nó ID", "nodeIdDescription": "O ID do nó existente que você deseja adoptar", "secretLabel": "Chave Secreta", "secretDescription": "A chave secreta do nó existente", "submitButton": "Nó Adotado" }, "generate": { "title": "Credenciais Geradas", "description": "Use estas credenciais geradas para configurar o nó", "nodeIdTitle": "Nó ID", "secretTitle": "Chave Secreta", "saveCredentialsTitle": "Adicionar Credenciais à Configuração", "saveCredentialsDescription": "Adicione essas credenciais ao arquivo de configuração do seu nodo de Pangolin auto-hospedado para completar a conexão.", "submitButton": "Criar nó" }, "validation": { "adoptRequired": "ID do nó e Segredo são necessários ao adotar um nó existente" }, "errors": { "loadDefaultsFailed": "Falha ao carregar padrões", "defaultsNotLoaded": "Padrões não carregados", "createFailed": "Falha ao criar nó" }, "success": { "created": "Nó criado com sucesso" } }, "remoteExitNodeSelection": "Seleção de nó", "remoteExitNodeSelectionDescription": "Selecione um nó para encaminhar o tráfego para este site local", "remoteExitNodeRequired": "Um nó deve ser seleccionado para sites locais", "noRemoteExitNodesAvailable": "Nenhum nó disponível", "noRemoteExitNodesAvailableDescription": "Nenhum nó está disponível para esta organização. Crie um nó primeiro para usar sites locais.", "exitNode": "Nodo de Saída", "country": "País", "rulesMatchCountry": "Atualmente baseado no IP de origem", "managedSelfHosted": { "title": "Gerenciado Auto-Hospedado", "description": "Servidor Pangolin auto-hospedado mais confiável e com baixa manutenção com sinos extras e assobiamentos", "introTitle": "Pangolin Auto-Hospedado Gerenciado", "introDescription": "é uma opção de implantação projetada para pessoas que querem simplicidade e confiança adicional, mantendo os seus dados privados e auto-hospedados.", "introDetail": "Com esta opção, você ainda roda seu próprio nó Pangolin — seus túneis, terminação SSL e tráfego todos permanecem no seu servidor. A diferença é que a gestão e a monitorização são geridos através do nosso painel de nuvem, que desbloqueia vários benefícios:", "benefitSimplerOperations": { "title": "Operações simples", "description": "Não é necessário executar o seu próprio servidor de e-mail ou configurar um alerta complexo. Você receberá fora de caixa verificações de saúde e alertas de tempo de inatividade." }, "benefitAutomaticUpdates": { "title": "Atualizações automáticas", "description": "O painel em nuvem evolui rapidamente, para que você obtenha novos recursos e correções de bugs sem ter de puxar manualmente novos contêineres toda vez." }, "benefitLessMaintenance": { "title": "Menos manutenção", "description": "Sem migrações, backups ou infraestrutura extra para gerir. Lidamos com isso na nuvem." }, "benefitCloudFailover": { "title": "Falha na nuvem", "description": "Se o seu nó descer, seus túneis podem falhar temporariamente nos nossos pontos de presença na nuvem até que você o traga online." }, "benefitHighAvailability": { "title": "Alta disponibilidade (Ppos)", "description": "Você também pode anexar vários nós à sua conta para um melhor desempenho." }, "benefitFutureEnhancements": { "title": "Aprimoramentos futuros", "description": "Estamos planejando adicionar mais análises, alertas e ferramentas de gerenciamento para tornar sua implantação ainda mais robusta." }, "docsAlert": { "text": "Saiba mais sobre a opção Hospedagem Auto-Gerenciada no nosso", "documentation": "documentação" }, "convertButton": "Converter este nó para Auto-Hospedado Gerenciado" }, "internationaldomaindetected": "Domínio Internacional Detectado", "willbestoredas": "Será armazenado como:", "roleMappingDescription": "Determinar como as funções são atribuídas aos usuários quando eles fazem login quando Auto Provisão está habilitada.", "selectRole": "Selecione uma função", "roleMappingExpression": "Expressão", "selectRolePlaceholder": "Escolha uma função", "selectRoleDescription": "Selecione uma função para atribuir a todos os usuários deste provedor de identidade", "roleMappingExpressionDescription": "Insira uma expressão JMESPath para extrair informações da função do token de ID", "idpTenantIdRequired": "ID do inquilino é necessária", "invalidValue": "Valor Inválido", "idpTypeLabel": "Tipo de provedor de identidade", "roleMappingExpressionPlaceholder": "ex.: Contem (grupos, 'administrador') && 'Administrador' 「'Membro'", "idpGoogleConfiguration": "Configuração do Google", "idpGoogleConfigurationDescription": "Configurar as credenciais do Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Segredo de cliente OAuth2 do Google", "idpAzureConfiguration": "Configuração de ID do Azure Entra", "idpAzureConfigurationDescription": "Configurar credenciais do Azure Entra ID OAuth2", "idpTenantId": "ID do Inquilino", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "ID do tenant Azure (encontrado na visão geral do diretório ativo Azure)", "idpAzureClientIdDescription": "ID cliente de registro do aplicativo Azure", "idpAzureClientSecretDescription": "Segredo cliente de registro do Azure App", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Configuração do Google", "idpAzureConfigurationTitle": "Configuração de ID do Azure Entra", "idpTenantIdLabel": "ID do Inquilino", "idpAzureClientIdDescription2": "ID cliente de registro do aplicativo Azure", "idpAzureClientSecretDescription2": "Segredo cliente de registro do Azure App", "idpGoogleDescription": "Provedor Google OAuth2/OIDC", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sub-rede", "subnetDescription": "A sub-rede para a configuração de rede dessa organização.", "customDomain": "Domínio Personalizado", "authPage": "Páginas de Autenticação", "authPageDescription": "Defina um domínio personalizado para as páginas de autenticação da organização", "authPageDomain": "Domínio de Página Autenticação", "authPageBranding": "Marcação Personalizada", "authPageBrandingDescription": "Configure a marcação que aparece nas páginas de autenticação para esta organização", "authPageBrandingUpdated": "Marca de Autenticação atualizada com sucesso", "authPageBrandingRemoved": "Marca de Autenticação removida com sucesso", "authPageBrandingRemoveTitle": "Remover Marca de Autenticação", "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)", "brandingOrgTitle": "Título para Página de Autenticação da Organização", "brandingOrgDescription": "{orgName} será substituído pelo nome da organização", "brandingOrgSubtitle": "Subtítulo para Página de Autenticação da Organização", "brandingResourceTitle": "Título para Página de Autenticação do Recurso", "brandingResourceSubtitle": "Subtítulo para Página de Autenticação do Recurso", "brandingResourceDescription": "{resourceName} será substituído pelo nome da organização", "saveAuthPageDomain": "Salvar Domínio", "saveAuthPageBranding": "Salvar Marcação", "removeAuthPageBranding": "Remover Marcação", "noDomainSet": "Nenhum domínio definido", "changeDomain": "Alterar domínio", "selectDomain": "Selecionar domínio", "restartCertificate": "Reiniciar Certificado", "editAuthPageDomain": "Editar Página de Autenticação", "setAuthPageDomain": "Definir domínio da página de autenticação", "failedToFetchCertificate": "Falha ao buscar o certificado", "failedToRestartCertificate": "Falha ao reiniciar o certificado", "addDomainToEnableCustomAuthPages": "Os usuários poderão acessar a página de login da organização e completar a autenticação do recurso usando este domínio.", "selectDomainForOrgAuthPage": "Selecione um domínio para a página de autenticação da organização", "domainPickerProvidedDomain": "Domínio fornecido", "domainPickerFreeProvidedDomain": "Domínio fornecido grátis", "domainPickerVerified": "Verificada", "domainPickerUnverified": "Não verificado", "domainPickerInvalidSubdomainStructure": "Este subdomínio contém caracteres ou estrutura inválidos. Ele será eliminado automaticamente quando você salvar.", "domainPickerError": "ERRO", "domainPickerErrorLoadDomains": "Falha ao carregar domínios da organização", "domainPickerErrorCheckAvailability": "Não foi possível verificar a disponibilidade do domínio", "domainPickerInvalidSubdomain": "Subdomínio inválido", "domainPickerInvalidSubdomainRemoved": "A entrada \"{sub}\" foi removida porque ela não é válida.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" não pôde ser válido para {domain}.", "domainPickerSubdomainSanitized": "Subdomínio banalizado", "domainPickerSubdomainCorrected": "\"{sub}\" foi corrigido para \"{sanitized}\"", "orgAuthSignInTitle": "Entrada da Organização", "orgAuthChooseIdpDescription": "Escolha o seu provedor de identidade para continuar", "orgAuthNoIdpConfigured": "Esta organização não tem nenhum provedor de identidade configurado. Você pode entrar com a identidade do seu Pangolin.", "orgAuthSignInWithPangolin": "Entrar com o Pangolin", "orgAuthSignInToOrg": "Fazer login em uma organização", "orgAuthSelectOrgTitle": "Entrada da Organização", "orgAuthSelectOrgDescription": "Digite seu ID da organização para continuar", "orgAuthOrgIdPlaceholder": "sua-organização", "orgAuthOrgIdHelp": "Digite o identificador único da sua organização", "orgAuthSelectOrgHelp": "Após inserir o seu ID da organização, você será redirecionado para a página de entrada da organização onde poderá usar SSO ou as credenciais da organização.", "orgAuthRememberOrgId": "Lembrar este ID da organização", "orgAuthBackToSignIn": "Voltar para entrada padrão", "orgAuthNoAccount": "Não possui uma conta?", "subscriptionRequiredToUse": "Uma assinatura é necessária para usar esse recurso.", "mustUpgradeToUse": "Você deve atualizar sua assinatura para usar este recurso.", "subscriptionRequiredTierToUse": "Esta função requer {tier} ou superior.", "upgradeToTierToUse": "Atualize para {tier} ou superior para usar este recurso.", "subscriptionTierTier1": "Residencial", "subscriptionTierTier2": "Equipe", "subscriptionTierTier3": "Empresas", "subscriptionTierEnterprise": "Empresa", "idpDisabled": "Provedores de identidade estão desabilitados.", "orgAuthPageDisabled": "A página de autenticação da organização está desativada.", "domainRestartedDescription": "Verificação de domínio reiniciado com sucesso", "resourceAddEntrypointsEditFile": "Editar arquivo: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Editar arquivo: docker-compose.yml", "emailVerificationRequired": "Verificação de e-mail é necessária. Por favor, faça login novamente via {dashboardUrl}/auth/login conclui esta etapa. Em seguida, volte aqui.", "twoFactorSetupRequired": "Configuração de autenticação de dois fatores é necessária. Por favor, entre novamente via {dashboardUrl}/auth/login conclua este passo. Em seguida, volte aqui.", "additionalSecurityRequired": "Segurança adicional necessária", "organizationRequiresAdditionalSteps": "Esta organização requer etapas de segurança adicionais antes que você possa acessar os recursos.", "completeTheseSteps": "Conclua estas etapas", "enableTwoFactorAuthentication": "Ativar autenticação de dois fatores", "completeSecuritySteps": "Passos de segurança completos", "securitySettings": "Configurações de Segurança", "dangerSection": "Zona de Perigo", "dangerSectionDescription": "Excluir permanentemente todos os dados associados a esta organização", "securitySettingsDescription": "Configurar políticas de segurança para a organização", "requireTwoFactorForAllUsers": "Exigir autenticação dupla para todos os usuários", "requireTwoFactorDescription": "Quando ativado, todos os usuários internos nesta organização devem ter a autenticação de dois fatores ativada para acessar a organização.", "requireTwoFactorDisabledDescription": "Este recurso requer uma licença válida (Enterprise) ou assinatura ativa (SaaS)", "requireTwoFactorCannotEnableDescription": "Você deve ativar a autenticação de dois fatores para sua conta antes de aplicá-la para todos os usuários", "maxSessionLength": "Comprimento Máximo da Sessão", "maxSessionLengthDescription": "Definir a duração máxima para as sessões dos usuários. Após esse tempo, os usuários precisarão autenticar novamente.", "maxSessionLengthDisabledDescription": "Este recurso requer uma licença válida (Enterprise) ou assinatura ativa (SaaS)", "selectSessionLength": "Selecionar duração da sessão", "unenforced": "Inforçado", "1Hour": "number@@0 horas", "3Hours": "3 horas", "6Hours": "6 horas", "12Hours": "12 horas", "1DaySession": "1 dia", "3Days": "3 dias", "7Days": "7 dias", "14Days": "14 dias", "30DaysSession": "30 dias", "90DaysSession": "90 dias", "180DaysSession": "180 dias", "passwordExpiryDays": "Expiração da Senha", "editPasswordExpiryDescription": "Defina o número de dias antes que os usuários sejam obrigados a mudar sua senha.", "selectPasswordExpiry": "Selecione a senha expirada", "30Days": "30 dias", "1Day": "1 dia", "60Days": "60 dias", "90Days": "90 dias", "180Days": "180 dias", "1Year": "1 ano", "subscriptionBadge": "Assinatura requerida", "securityPolicyChangeWarning": "Aviso de Mudança da Política de Segurança", "securityPolicyChangeDescription": "Você está prestes a alterar as configurações da política de segurança. Depois de salvar, talvez você precise se autenticar novamente para cumprir estas atualizações de política. Todos os usuários que não estiverem em conformidade também precisarão reautenticar.", "securityPolicyChangeConfirmMessage": "Eu confirmo", "securityPolicyChangeWarningText": "Isso afetará todos os usuários da organização", "authPageErrorUpdateMessage": "Ocorreu um erro ao atualizar as configurações da página de autenticação", "authPageErrorUpdate": "Não é possível atualizar a página de autenticação", "authPageDomainUpdated": "Domínio da Página de Autenticação atualizado com sucesso", "healthCheckNotAvailable": "Localização", "rewritePath": "Reescrever Caminho", "rewritePathDescription": "Opcionalmente reescreva o caminho antes de encaminhar ao destino.", "continueToApplication": "Continuar para o aplicativo", "checkingInvite": "Checando convite", "setResourceHeaderAuth": "setResourceHeaderAuth", "resourceHeaderAuthRemove": "Remover autenticação de cabeçalho", "resourceHeaderAuthRemoveDescription": "Autenticação de cabeçalho removida com sucesso.", "resourceErrorHeaderAuthRemove": "Falha ao remover autenticação de cabeçalho", "resourceErrorHeaderAuthRemoveDescription": "Não foi possível remover a autenticação do cabeçalho para o recurso.", "resourceHeaderAuthProtectionEnabled": "Autenticação de Cabeçalho Habilitada", "resourceHeaderAuthProtectionDisabled": "Autenticação de Cabeçalho Desativada", "headerAuthRemove": "Remover autenticação de cabeçalho", "headerAuthAdd": "Adicionar Autenticação do Cabeçalho", "resourceErrorHeaderAuthSetup": "Falha ao definir autenticação de cabeçalho", "resourceErrorHeaderAuthSetupDescription": "Não foi possível definir a autenticação do cabeçalho para o recurso.", "resourceHeaderAuthSetup": "Autenticação de Cabeçalho definida com sucesso", "resourceHeaderAuthSetupDescription": "Autenticação de cabeçalho foi definida com sucesso.", "resourceHeaderAuthSetupTitle": "Definir autenticação de cabeçalho", "resourceHeaderAuthSetupTitleDescription": "Defina as credenciais de autenticação básica (nome de usuário e senha) para proteger este recurso com a Autenticação de Cabeçalho HTTP. Acessá-lo usando o formato https://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Definir autenticação de cabeçalho", "actionSetResourceHeaderAuth": "Definir autenticação de cabeçalho", "enterpriseEdition": "Edição Enterprise", "unlicensed": "Sem licença", "beta": "Beta", "manageUserDevices": "Dispositivos do usuário", "manageUserDevicesDescription": "Ver e gerenciar dispositivos que os usuários usam para se conectar de forma privada aos recursos", "downloadClientBannerTitle": "Baixar Cliente Pangolin", "downloadClientBannerDescription": "Baixe o cliente Pangolin para seu sistema e conecte-se à rede Pangolin para acessar recursos de forma privada.", "manageMachineClients": "Gerenciar Clientes de Máquina", "manageMachineClientsDescription": "Crie e gerencie clientes que servidores e sistemas usam para se conectar de forma privada aos recursos", "machineClientsBannerTitle": "Servidores e Sistemas Automatizados", "machineClientsBannerDescription": "Clientes de máquina são para servidores e sistemas automatizados que não estão associados a um usuário específico. Eles autenticam com um ID e segredo, e podem ser executados com CLI Pangolin, CLI Olm, ou Olm como um contêiner.", "machineClientsBannerPangolinCLI": "CLI de Pangolin", "machineClientsBannerOlmCLI": "CLI Olm", "machineClientsBannerOlmContainer": "Contêiner Olm", "clientsTableUserClients": "Utilizador", "clientsTableMachineClients": "Máquina", "licenseTableValidUntil": "Válido até", "saasLicenseKeysSettingsTitle": "Licenças empresariais", "saasLicenseKeysSettingsDescription": "Gerar e gerenciar chaves de licença Enterprise para instâncias Pangolin auto-hospedadas", "sidebarEnterpriseLicenses": "Licenças", "generateLicenseKey": "Gerar Chave de Licença", "generateLicenseKeyForm": { "validation": { "emailRequired": "Por favor, insira um endereço de e-mail válido", "useCaseTypeRequired": "Por favor, selecione um tipo de caso de uso", "firstNameRequired": "O primeiro nome é obrigatório", "lastNameRequired": "Último nome é obrigatório", "primaryUseRequired": "Descreva seu uso primário", "jobTitleRequiredBusiness": "O título do trabalho é necessário para o uso de negócios", "industryRequiredBusiness": "Indústria é necessária para uso de negócios", "stateProvinceRegionRequired": "Estado/Província/Região é necessário", "postalZipCodeRequired": "Código postal/CEP é obrigatório", "companyNameRequiredBusiness": "O nome da empresa é necessário para uso empresarial", "countryOfResidenceRequiredBusiness": "O país de residência é necessário para a utilização da empresa", "countryRequiredPersonal": "País é necessário para uso pessoal", "agreeToTermsRequired": "Você deve concordar com os termos", "complianceConfirmationRequired": "Você deve confirmar o cumprimento da Licença Fossorial Comercial" }, "useCaseOptions": { "personal": { "title": "Uso pessoal", "description": "Para uso individual, não comercial, como aprendizagem, projetos pessoais ou experimentação." }, "business": { "title": "Uso de Negócios", "description": "Para uso em organizações, empresas ou atividades comerciais ou geradoras de receitas." } }, "steps": { "emailLicenseType": { "title": "Tipo de Email e Licença", "description": "Digite seu e-mail e escolha seu tipo de licença" }, "personalInformation": { "title": "Informações Pessoais", "description": "Conte-nos sobre você" }, "contactInformation": { "title": "Informação do Contato", "description": "Suas informações de contato" }, "termsGenerate": { "title": "Termos & Gerar", "description": "Revise e aceite os termos para gerar a sua licença" } }, "alerts": { "commercialUseDisclosure": { "title": "Divulgação de uso", "description": "Selecione o nível de licença que reflete corretamente seu uso pretendido. A Licença Pessoal permite o uso livre do Software para atividades comerciais individuais, não comerciais ou em pequena escala com rendimento bruto anual inferior a 100.000 USD. Qualquer uso além destes limites — incluindo uso dentro de um negócio, organização, ou outro ambiente gerador de receitas — requer uma Licença Enterprise válida e o pagamento da taxa aplicável de licenciamento. Todos os usuários, pessoais ou empresariais, devem cumprir os Termos da Licença Comercial Fossorial." }, "trialPeriodInformation": { "title": "Informações do Período de Avaliação", "description": "Esta Chave de Licença permite recursos da Empresa para um período de avaliação de 7 dias. O acesso contínuo a Recursos Pagos além do período de avaliação requer ativação sob uma Licença Pessoal ou Empresarial válida. Para licenciamento Empresarial, entre em contato com sales@pangolin.net." } }, "form": { "useCaseQuestion": "Você está usando o Pangolin para uso pessoal ou empresarial?", "firstName": "Primeiro nome", "lastName": "Último Nome", "jobTitle": "Título do Cargo", "primaryUseQuestion": "Para que você pretende usar o Pangolin em primeiro lugar?", "industryQuestion": "O que é a sua indústria?", "prospectiveUsersQuestion": "Quantos usuários potenciais você espera?", "prospectiveSitesQuestion": "Quantos sites (túneis) você espera ter?", "companyName": "Nome Empresa", "countryOfResidence": "País de residência", "stateProvinceRegion": "Estado / Província / Região", "postalZipCode": "Código Postal / Postal", "companyWebsite": "Site da empresa", "companyPhoneNumber": "Número de telefone empresa", "country": "País", "phoneNumberOptional": "Número de telefone (opcional)", "complianceConfirmation": "Confirmo que a informação que forneci é correcta e que estou em conformidade com a Licença Comercial Fossorial. Reportar informações imprecisas ou identificar mal o uso do produto é uma violação da licença e pode resultar em sua chave ser revogada." }, "buttons": { "close": "Fechar", "previous": "Anterior", "next": "Próximo", "generateLicenseKey": "Gerar Chave de Licença" }, "toasts": { "success": { "title": "Chave de licença gerada com sucesso", "description": "Sua chave de licença foi gerada e está pronta para ser usada." }, "error": { "title": "Falha ao gerar chave de licença", "description": "Ocorreu um erro ao gerar a chave da licença." } } }, "newPricingLicenseForm": { "title": "Obtenha uma licença", "description": "Escolha um plano e nos diga como você planeja usar o Pangolin.", "chooseTier": "Escolha seu plano", "viewPricingLink": "Veja os preços, recursos e limites", "tiers": { "starter": { "title": "Iniciante", "description": "Recursos de empresa, 25 usuários, 25 sites e apoio da comunidade." }, "scale": { "title": "Escala", "description": "Recursos de empresa, 50 usuários, 50 sites e apoio prioritário." } }, "personalUseOnly": "Apenas uso pessoal (licença gratuita — sem check-out)", "buttons": { "continueToCheckout": "Continuar com checkout" }, "toasts": { "checkoutError": { "title": "Erro no check-out", "description": "Não foi possível iniciar o checkout. Por favor, tente novamente." } } }, "priority": "Prioridade", "priorityDescription": "Rotas de alta prioridade são avaliadas primeiro. Prioridade = 100 significa ordem automática (decisões do sistema). Use outro número para aplicar prioridade manual.", "instanceName": "Nome da Instância", "pathMatchModalTitle": "Configurar Correspondência de Caminho", "pathMatchModalDescription": "Configure como as solicitações de entrada devem ser correspondidas com base no caminho.", "pathMatchType": "Tipo de Correspondência", "pathMatchPrefix": "Prefixo", "pathMatchExact": "Exato", "pathMatchRegex": "Regex", "pathMatchValue": "Valor do caminho", "clear": "Limpar", "saveChanges": "Salvar as alterações", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/caminho", "pathMatchPrefixHelp": "Exemplo: /api matches /api, /api/users, etc.", "pathMatchExactHelp": "Exemplo: /api match only /api", "pathMatchRegexHelp": "Exemplo: ^/api/.* Corresponde /api/anything", "pathRewriteModalTitle": "Configurar Caminho de Reescrita", "pathRewriteModalDescription": "Transforme o caminho correspondente antes de encaminhar para o alvo.", "pathRewriteType": "Reescrever o tipo", "pathRewritePrefixOption": "Prefixo - substituir prefixo", "pathRewriteExactOption": "Exato - Substituir o caminho inteiro", "pathRewriteRegexOption": "Regex - Substituição de padrão", "pathRewriteStripPrefixOption": "Prefixo do Strip - Remover prefixo", "pathRewriteValue": "Reescrever Valor", "pathRewriteRegexPlaceholder": "/new/$1", "pathRewriteDefaultPlaceholder": "/novo-caminho", "pathRewritePrefixHelp": "Substituir o prefixo correspondente por este valor", "pathRewriteExactHelp": "Substitua o caminho inteiro por este valor quando o caminho corresponder exatamente", "pathRewriteRegexHelp": "Usar grupos de captura como $1, $2 para substituição", "pathRewriteStripPrefixHelp": "Deixe em branco para remover prefixo ou fornecer novo prefixo", "pathRewritePrefix": "Prefixo", "pathRewriteExact": "Exato", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Tirar", "pathRewriteStripLabel": "faixa", "sidebarEnableEnterpriseLicense": "Habilitar Licença Empresarial", "cannotbeUndone": "Isso não pode ser desfeito.", "toConfirm": "para confirmar.", "deleteClientQuestion": "Você tem certeza que deseja remover o cliente do site e da organização?", "clientMessageRemove": "Depois de removido, o cliente não poderá mais se conectar ao site.", "sidebarLogs": "Registros", "request": "Pedir", "requests": "Solicitações", "logs": "Registros", "logsSettingsDescription": "Monitore os logs coletados desta organização", "searchLogs": "Pesquisar registros...", "action": "Acão", "actor": "Ator", "timestamp": "Timestamp", "accessLogs": "Logs de Acesso", "exportCsv": "Exportar como CSV", "exportError": "Erro desconhecido ao exportar CSV", "exportCsvTooltip": "Dentro do Intervalo de Tempo", "actorId": "ID do ator", "allowedByRule": "Permitido por regra", "allowedNoAuth": "Não Permitido Nenhuma Autenticação", "validAccessToken": "Token de acesso válido", "validHeaderAuth": "Valid header auth", "validPincode": "Valid Pincode", "validPassword": "Senha válida", "validEmail": "Valid email", "validSSO": "Valid SSO", "resourceBlocked": "Recurso bloqueado", "droppedByRule": "Derrubado pela regra", "noSessions": "Sem Sessões", "temporaryRequestToken": "Token de solicitação temporária", "noMoreAuthMethods": "No Valid Auth", "ip": "PI", "reason": "Motivo", "requestLogs": "Registro de pedidos", "requestAnalytics": "Solicitar análise", "host": "Servidor", "location": "Local:", "actionLogs": "Logs de Ações", "sidebarLogsRequest": "Registro de pedidos", "sidebarLogsAccess": "Logs de Acesso", "sidebarLogsAction": "Logs de Ações", "logRetention": "Retenção de Log", "logRetentionDescription": "Gerenciar quanto tempo os diferentes tipos de logs são mantidos para esta organização ou desativá-los", "requestLogsDescription": "Ver registros de pedidos detalhados de recursos nesta organização", "requestAnalyticsDescription": "Exibir análise detalhada de pedidos para recursos nesta organização", "logRetentionRequestLabel": "Solicitar retenção de registro", "logRetentionRequestDescription": "Por quanto tempo manter os registros de pedidos", "logRetentionAccessLabel": "Retenção de Log de Acesso", "logRetentionAccessDescription": "Por quanto tempo manter os registros de acesso", "logRetentionActionLabel": "Ação de Retenção no Log", "logRetentionActionDescription": "Por quanto tempo manter os registros de ação", "logRetentionDisabled": "Desabilitado", "logRetention3Days": "3 dias", "logRetention7Days": "7 dias", "logRetention14Days": "14 dias", "logRetention30Days": "30 dias", "logRetention90Days": "90 dias", "logRetentionForever": "Permanentemente", "logRetentionEndOfFollowingYear": "Fim do ano seguinte", "actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização", "accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização", "licenseRequiredToUse": "Uma licença Enterprise Edition é necessária para usar este recurso. Este recurso também está disponível no Pangolin Cloud.", "ossEnterpriseEditionRequired": "O Enterprise Edition é necessário para usar este recurso. Este recurso também está disponível no Pangolin Cloud.", "certResolver": "Resolvedor de Certificado", "certResolverDescription": "Selecione o resolvedor de certificados para este recurso.", "selectCertResolver": "Selecionar solucionador de certificado", "enterCustomResolver": "Inserir Resolvedor Personalizado", "preferWildcardCert": "Prefere Certificado Wildcard", "unverified": "Não verificado", "domainSetting": "Configurações do domínio", "domainSettingDescription": "Configurar configurações para o domínio", "preferWildcardCertDescription": "Tente gerar um certificado curingado (requer um resolvedor de certificado configurado corretamente).", "recordName": "Nome da gravação", "auto": "Automático", "TTL": "TTL", "howToAddRecords": "Como adicionar registros", "dnsRecord": "Registros DNS", "required": "Obrigatório", "domainSettingsUpdated": "Configurações de domínio atualizadas com sucesso", "orgOrDomainIdMissing": "ID da organização ou domínio está faltando", "loadingDNSRecords": "Carregando registros DNS...", "olmUpdateAvailableInfo": "Uma versão atualizada do Olm está disponível. Atualize para a versão mais recente para ter a melhor experiência.", "client": "Cliente", "proxyProtocol": "Configurações de Protocolo Proxy", "proxyProtocolDescription": "Configurar o protocolo proxy para preservar endereços IP do cliente para serviços TCP.", "enableProxyProtocol": "Habilitar protocolo proxy", "proxyProtocolInfo": "Preservar endereços IP do cliente para backends TCP", "proxyProtocolVersion": "Versão do Protocolo Proxy", "version1": " Versão 1 (recomendado)", "version2": "Versão 2", "versionDescription": "A versão 1 é baseada em texto e amplamente suportada. A versão 2 é binária e mais eficiente, mas menos compatível.", "warning": "ATENÇÃO", "proxyProtocolWarning": "A aplicação de backend deve ser configurada para aceitar conexões de protocolo proxy. Se o seu backend não suporta o Protocolo de Proxy, habilitando isto quebrará todas as conexões, então só habilite isso se você souber o que está fazendo. Certifique-se de configurar seu backend para confiar nos cabeçalhos do protocolo proxy no Traefik.", "restarting": "Reiniciando...", "manual": "Manualmente", "messageSupport": "Suporte a Mensagens", "supportNotAvailableTitle": "Suporte Não Disponível", "supportNotAvailableDescription": "Não está disponível no momento. Você pode enviar um e-mail para support@pangolin.net.", "supportRequestSentTitle": "Pedido de suporte enviado", "supportRequestSentDescription": "Sua mensagem foi enviada com sucesso.", "supportRequestFailedTitle": "Falha ao enviar solicitação", "supportRequestFailedDescription": "Ocorreu um erro ao enviar sua solicitação de suporte.", "supportSubjectRequired": "Assunto é necessária", "supportSubjectMaxLength": "O assunto deve ter 255 caracteres ou menos", "supportMessageRequired": "A mensagem é obrigatória", "supportReplyTo": "Responder a", "supportSubject": "Cargo", "supportSubjectPlaceholder": "Digite o assunto", "supportMessage": "mensagem", "supportMessagePlaceholder": "Digite sua mensagem", "supportSending": "Enviando...", "supportSend": "Mandar", "supportMessageSent": "Mensagem enviada!", "supportWillContact": "Entraremos em contato em breve!", "selectLogRetention": "Selecionar retenção de log", "terms": "Termos", "privacy": "Privacidade", "security": "Segurança", "docs": "Documentação", "deviceActivation": "Ativação do dispositivo", "deviceCodeInvalidFormat": "O código deve ter 9 caracteres (ex.: A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Código inválido ou expirado", "deviceCodeVerifyFailed": "Falha ao verificar o código do dispositivo", "deviceCodeValidating": "Validando código do dispositivo...", "deviceCodeVerifying": "Verificando autorização do dispositivo...", "signedInAs": "Sessão iniciada como", "deviceCodeEnterPrompt": "Digite o código exibido no dispositivo", "continue": "Continuar", "deviceUnknownLocation": "Localização desconhecida", "deviceAuthorizationRequested": "Esta autorização foi solicitada por {location} no {date}. Certifique-se de que você confia neste dispositivo, pois ele terá acesso à conta.", "deviceLabel": "Dispositivo: {deviceName}", "deviceWantsAccess": "quer acessar sua conta", "deviceExistingAccess": "Acesso existente:", "deviceFullAccess": "Acesso total à sua conta", "deviceOrganizationsAccess": "Acesso a todas as organizações que sua conta tem acesso a", "deviceAuthorize": "Autorizar {applicationName}", "deviceConnected": "Dispositivo Conectado!", "deviceAuthorizedMessage": "O dispositivo está autorizado a acessar sua conta. Por favor, retorne ao aplicativo cliente.", "pangolinCloud": "Nuvem do Pangolin", "viewDevices": "Ver Dispositivos", "viewDevicesDescription": "Gerencie seus dispositivos conectados", "noDevices": "Nenhum dispositivo encontrado", "dateCreated": "Data de Criação", "unnamedDevice": "Dispositivo sem nome", "deviceQuestionRemove": "Você tem certeza que deseja excluir este dispositivo?", "deviceMessageRemove": "Esta ação não pode ser desfeita.", "deviceDeleteConfirm": "Excluir dispositivo", "deleteDevice": "Excluir dispositivo", "errorLoadingDevices": "Erro ao carregar dispositivos", "failedToLoadDevices": "Falha ao carregar dispositivos", "deviceDeleted": "Dispositivo excluído", "deviceDeletedDescription": "O dispositivo foi excluído com sucesso.", "errorDeletingDevice": "Erro ao excluir dispositivo", "failedToDeleteDevice": "Falha ao excluir dispositivo", "showColumns": "Exibir Colunas", "hideColumns": "Ocultar colunas", "columnVisibility": "Visibilidade da Coluna", "toggleColumn": "Alternar coluna {columnName}", "allColumns": "Todas as colunas", "defaultColumns": "Colunas padrão", "customizeView": "Personalizar visualização", "viewOptions": "Opções de visualização", "selectAll": "Selecionar Todos", "selectNone": "Não selecionar nada", "selectedResources": "Recursos Selecionados", "enableSelected": "Habilitar Selecionados", "disableSelected": "Desativar Selecionados", "checkSelectedStatus": "Status de Verificação dos Selecionados", "clients": "Clientes", "accessClientSelect": "Selecionar clientes de máquina", "resourceClientDescription": "Clientes de máquina que podem acessar este recurso", "regenerate": "Regenerar", "credentials": "Credenciais", "savecredentials": "Salvar Credenciais", "regenerateCredentialsButton": "Regerar Credenciais", "regenerateCredentials": "Regerar Credenciais", "generatedcredentials": "Credenciais Geradas", "copyandsavethesecredentials": "Copiar e salvar estas credenciais", "copyandsavethesecredentialsdescription": "Essas credenciais não serão exibidas novamente depois que você sair desta página. Salve elas com segurança agora.", "credentialsSaved": "Credenciais salvas", "credentialsSavedDescription": "As credenciais foram regeneradas e salvas com sucesso.", "credentialsSaveError": "Erro ao Salvar Credenciais", "credentialsSaveErrorDescription": "Ocorreu um erro enquanto regenerava e salvava as credenciais.", "regenerateCredentialsWarning": "Regenerar credenciais irá invalidar as anteriores e causar uma desconexão. Certifique-se de atualizar quaisquer configurações que usam essas credenciais.", "confirm": "Confirmar", "regenerateCredentialsConfirmation": "Você tem certeza que deseja recriar as credenciais?", "endpoint": "Endpoint", "Id": "Id", "SecretKey": "Chave secreta", "niceId": "Belo ID", "niceIdUpdated": "Bom ID atualizado", "niceIdUpdatedSuccessfully": "Bom ID atualizado com sucesso", "niceIdUpdateError": "Erro ao atualizar Nice ID", "niceIdUpdateErrorDescription": "Ocorreu um erro ao atualizar a ID de Nice.", "niceIdCannotBeEmpty": "Bom ID não pode estar vazio", "enterIdentifier": "Inserir identificador", "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Não é você? Use uma conta diferente.", "deviceLoginDeviceRequestingAccessToAccount": "Um dispositivo está solicitando acesso a essa conta.", "loginSelectAuthenticationMethod": "Selecione um método de autenticação para continuar.", "noData": "Nenhum dado encontrado", "machineClients": "Clientes de máquina", "install": "Instale", "run": "Executar", "clientNameDescription": "O nome de exibição do cliente que pode ser alterado mais tarde.", "clientAddress": "Endereço do Cliente (Avançado)", "setupFailedToFetchSubnet": "Falha ao buscar a subrede padrão", "setupSubnetAdvanced": "Sub-rede (Avançado)", "setupSubnetDescription": "A sub-rede para a rede interna desta organização.", "setupUtilitySubnet": "Sub-rede Utilitária (Avançado)", "setupUtilitySubnetDescription": "A sub-rede para os endereços de alias e servidor DNS desta organização.", "siteRegenerateAndDisconnect": "Regerar e Desconectar", "siteRegenerateAndDisconnectConfirmation": "Você tem certeza que deseja regenerar as credenciais e desconectar este site?", "siteRegenerateAndDisconnectWarning": "Isto irá regenerar as credenciais e desconectar imediatamente o site. O site precisará ser reiniciado com as novas credenciais.", "siteRegenerateCredentialsConfirmation": "Você tem certeza que deseja regenerar as credenciais para este site?", "siteRegenerateCredentialsWarning": "Isso irá regenerar as credenciais. O site permanecerá conectado até que você reinicie-o manualmente e use as novas credenciais.", "clientRegenerateAndDisconnect": "Regerar e Desconectar", "clientRegenerateAndDisconnectConfirmation": "Tem certeza que deseja regenerar as credenciais e desconectar este cliente?", "clientRegenerateAndDisconnectWarning": "Isto irá regenerar as credenciais e desconectar o cliente imediatamente. O cliente precisará ser reiniciado com as novas credenciais.", "clientRegenerateCredentialsConfirmation": "Tem certeza que deseja regenerar as credenciais para este cliente?", "clientRegenerateCredentialsWarning": "Isto irá regenerar as credenciais. O cliente permanecerá conectado até você reiniciá-lo manualmente e usar as novas credenciais.", "remoteExitNodeRegenerateAndDisconnect": "Regerar e Desconectar", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Tem certeza que deseja regenerar as credenciais e desconectar este nó de saída remota?", "remoteExitNodeRegenerateAndDisconnectWarning": "Isto irá regenerar as credenciais e desconectar imediatamente o nó de saída remota. O nó de saída remota precisará ser reiniciado com as novas credenciais.", "remoteExitNodeRegenerateCredentialsConfirmation": "Você tem certeza que deseja regenerar as credenciais para este nó de saída remota?", "remoteExitNodeRegenerateCredentialsWarning": "Isto irá regenerar as credenciais. O nó de saída remota permanecerá conectado até que você o reinicie manualmente e use as novas credenciais.", "agent": "Representante", "personalUseOnly": "Uso Pessoal Apenas", "loginPageLicenseWatermark": "Esta instância está licenciada apenas para uso pessoal.", "instanceIsUnlicensed": "Esta instância não está licenciada.", "portRestrictions": "Restrições de Porta", "allPorts": "Todos", "custom": "Personalizado", "allPortsAllowed": "Todas as Portas Permitidas", "allPortsBlocked": "Todas as Portas Bloqueadas", "tcpPortsDescription": "Especifique quais portas TCP são permitidas para este recurso. Use '*' para todas as portas, deixe vazio para bloquear todas, ou insira uma lista de portas separadas por vírgulas e intervalos (por exemplo, 80,443,8000-9000).", "udpPortsDescription": "Especifique quais portas UDP são permitidas para este recurso. Use '*' para todas as portas, deixe vazio para bloquear todas, ou insira uma lista de portas separadas por vírgulas e intervalos (por exemplo, 53,123,500-600).", "organizationLoginPageTitle": "Página de Login da Organização", "organizationLoginPageDescription": "Personalize a página de login para esta organização", "resourceLoginPageTitle": "Página de Login de Recurso", "resourceLoginPageDescription": "Personalize a página de login para recursos individuais", "enterConfirmation": "Inserir confirmação", "blueprintViewDetails": "Detalhes", "defaultIdentityProvider": "Provedor de Identidade Padrão", "defaultIdentityProviderDescription": "Quando um provedor de identidade padrão for selecionado, o usuário será automaticamente redirecionado para o provedor de autenticação.", "editInternalResourceDialogNetworkSettings": "Configurações de Rede", "editInternalResourceDialogAccessPolicy": "Política de Acesso", "editInternalResourceDialogAddRoles": "Adicionar Funções", "editInternalResourceDialogAddUsers": "Adicionar Usuários", "editInternalResourceDialogAddClients": "Adicionar Clientes", "editInternalResourceDialogDestinationLabel": "Destino", "editInternalResourceDialogDestinationDescription": "Especifique o endereço de destino para o recurso interno. Isso pode ser um nome de host, endereço IP ou intervalo CIDR, dependendo do modo selecionado. Opcionalmente, defina um alias interno de DNS para facilitar a identificação.", "editInternalResourceDialogPortRestrictionsDescription": "Restrinja o acesso a portas TCP/UDP específicas ou permita/bloqueie todas as portas.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Controle de Acesso", "editInternalResourceDialogAccessControlDescription": "Controle quais funções, usuários e clientes de máquina podem acessar este recurso quando conectados. Os administradores sempre têm acesso.", "editInternalResourceDialogPortRangeValidationError": "O intervalo de portas deve ser \"*\" para todas as portas, ou uma lista de portas e intervalos separados por vírgulas (por exemplo, \"80,443,8000-9000\"). As portas devem estar entre 1 e 65535.", "internalResourceAuthDaemonStrategy": "Local do Daemon de autenticação SSH", "internalResourceAuthDaemonStrategyDescription": "Escolha onde o daemon de autenticação SSH funciona: no site (Newt) ou em um host remoto.", "internalResourceAuthDaemonDescription": "A autenticação SSH daemon lida com assinatura de chave SSH e autenticação PAM para este recurso. Escolha se ele é executado no site (Newt) ou em um host remoto separado. Veja a documentação para mais informações.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Selecione a estratégia", "internalResourceAuthDaemonStrategyLabel": "Local:", "internalResourceAuthDaemonSite": "No Site", "internalResourceAuthDaemonSiteDescription": "O serviço de autenticação é executado no site (Newt).", "internalResourceAuthDaemonRemote": "Host Remoto", "internalResourceAuthDaemonRemoteDescription": "O serviço de autenticação é executado em um host que não é o site.", "internalResourceAuthDaemonPort": "Porta do Daemon (opcional)", "orgAuthWhatsThis": "Onde posso encontrar meu ID da organização?", "learnMore": "Saiba mais", "backToHome": "Voltar para a página inicial", "needToSignInToOrg": "Precisa usar o provedor de identidade da sua organização?", "maintenanceMode": "Modo de Manutenção", "maintenanceModeDescription": "Exibir uma página de manutenção para os visitantes", "maintenanceModeType": "Tipo de Modo de Manutenção", "showMaintenancePage": "Mostrar uma página de manutenção para os visitantes", "enableMaintenanceMode": "Ativar Modo de Manutenção", "automatic": "Automático", "automaticModeDescription": "Exibir página de manutenção apenas quando todos os destinos de back-end estiverem inativos ou não saudáveis. Seu recurso continua funcionando normalmente desde que pelo menos um destino esteja saudável.", "forced": "Forçado", "forcedModeDescription": "Sempre mostre a página de manutenção, independentemente da saúde do backend. Use isso para manutenção planejada quando você deseja impedir todo acesso.", "warning:": "Aviso:", "forcedeModeWarning": "Todo o tráfego será direcionado para a página de manutenção. Seus recursos de back-end não receberão nenhuma solicitação.", "pageTitle": "Título da Página", "pageTitleDescription": "O título principal exibido na página de manutenção", "maintenancePageMessage": "Mensagem de Manutenção", "maintenancePageMessagePlaceholder": "Voltaremos em breve! Nosso site está passando por manutenção programada.", "maintenancePageMessageDescription": "Mensagem detalhada explicando a manutenção", "maintenancePageTimeTitle": "Hora de Conclusão Estimada (Opcional)", "maintenanceTime": "por exemplo, 2 horas, 1 de Nov às 17h00", "maintenanceEstimatedTimeDescription": "Quando você espera que a manutenção seja concluída", "editDomain": "Editar Domínio", "editDomainDescription": "Selecione um domínio para o seu recurso", "maintenanceModeDisabledTooltip": "Este recurso requer uma licença válida para ativar.", "maintenanceScreenTitle": "Serviço Temporariamente Indisponível", "maintenanceScreenMessage": "Estamos enfrentando dificuldades técnicas no momento. Por favor, volte em breve.", "maintenanceScreenEstimatedCompletion": "Conclusão Estimada:", "createInternalResourceDialogDestinationRequired": "Destino é obrigatório", "available": "Disponível", "archived": "Arquivado", "noArchivedDevices": "Nenhum dispositivo arquivado encontrado", "deviceArchived": "Dispositivo arquivado", "deviceArchivedDescription": "O dispositivo foi arquivado com sucesso.", "errorArchivingDevice": "Erro ao arquivar dispositivo", "failedToArchiveDevice": "Falha ao arquivar dispositivo", "deviceQuestionArchive": "Tem certeza que deseja arquivar este dispositivo?", "deviceMessageArchive": "O dispositivo será arquivado e removido da sua lista de dispositivos ativos.", "deviceArchiveConfirm": "Arquivar dispositivo", "archiveDevice": "Arquivar dispositivo", "archive": "Arquivo", "deviceUnarchived": "Dispositivo desarquivado", "deviceUnarchivedDescription": "O dispositivo foi desarquivado com sucesso.", "errorUnarchivingDevice": "Erro ao desarquivar dispositivo", "failedToUnarchiveDevice": "Falha ao desarquivar dispositivo", "unarchive": "Desarquivar", "archiveClient": "Arquivar Cliente", "archiveClientQuestion": "Tem certeza que deseja arquivar este cliente?", "archiveClientMessage": "O cliente será arquivado e removido da sua lista de clientes ativos.", "archiveClientConfirm": "Arquivar Cliente", "blockClient": "Bloco do Cliente", "blockClientQuestion": "Tem certeza que deseja bloquear este cliente?", "blockClientMessage": "O dispositivo será forçado a desconectar se estiver conectado. Você pode desbloquear o dispositivo mais tarde.", "blockClientConfirm": "Bloco do Cliente", "active": "ativo", "usernameOrEmail": "Usuário ou Email", "selectYourOrganization": "Selecione sua organização", "signInTo": "Iniciar sessão em", "signInWithPassword": "Continuar com a senha", "noAuthMethodsAvailable": "Nenhum método de autenticação disponível para esta organização.", "enterPassword": "Digite sua senha", "enterMfaCode": "Insira o código do seu aplicativo autenticador", "securityKeyRequired": "Por favor, utilize sua chave de segurança para entrar.", "needToUseAnotherAccount": "Precisa usar uma conta diferente?", "loginLegalDisclaimer": "Ao clicar nos botões abaixo, você reconhece que leu, entende e concorda com os Termos de Serviço e a Política de Privacidade.", "termsOfService": "Termos de Serviço", "privacyPolicy": "Política de Privacidade", "userNotFoundWithUsername": "Nenhum usuário encontrado com este nome de usuário.", "verify": "Verificar", "signIn": "Iniciar sessão", "forgotPassword": "Esqueceu a senha?", "orgSignInTip": "Se você já fez login antes, você pode digitar seu nome de usuário ou e-mail acima para autenticar com o provedor de identidade da sua organização. É mais fácil!", "continueAnyway": "Continuar mesmo assim", "dontShowAgain": "Não mostrar novamente", "orgSignInNotice": "Você sabia?", "signupOrgNotice": "Tentando fazer login?", "signupOrgTip": "Você está tentando entrar através do provedor de identidade da sua organização?", "signupOrgLink": "Faça login ou inscreva-se com sua organização em vez disso", "verifyEmailLogInWithDifferentAccount": "Use uma Conta Diferente", "logIn": "Iniciar sessão", "deviceInformation": "Informações do dispositivo", "deviceInformationDescription": "Informações sobre o dispositivo e o agente", "deviceSecurity": "Segurança do dispositivo", "deviceSecurityDescription": "Informações sobre postagem de segurança", "platform": "Plataforma", "macosVersion": "Versão do macOS", "windowsVersion": "Versão do Windows", "iosVersion": "Versão para iOS", "androidVersion": "Versão do Android", "osVersion": "Versão do SO", "kernelVersion": "Versão do Kernel", "deviceModel": "Modelo do dispositivo", "serialNumber": "Número de Série", "hostname": "Hostname", "firstSeen": "Visto primeiro", "lastSeen": "Visto por último", "biometricsEnabled": "Biometria habilitada", "diskEncrypted": "Disco criptografado", "firewallEnabled": "Firewall habilitado", "autoUpdatesEnabled": "Atualizações Automáticas Habilitadas", "tpmAvailable": "TPM disponível", "windowsAntivirusEnabled": "Antivírus habilitado", "macosSipEnabled": "Proteção da Integridade do Sistema (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Modo Furtivo do Firewall", "linuxAppArmorEnabled": "AppArmor", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "Ver informações e configurações do dispositivo", "devicePendingApprovalDescription": "Este dispositivo está aguardando aprovação", "deviceBlockedDescription": "Este dispositivo está bloqueado no momento. Ele não será capaz de se conectar a qualquer recurso a menos que seja desbloqueado.", "unblockClient": "Desbloquear Cliente", "unblockClientDescription": "O dispositivo foi desbloqueado", "unarchiveClient": "Desarquivar Cliente", "unarchiveClientDescription": "O dispositivo foi desarquivado", "block": "Bloquear", "unblock": "Desbloquear", "deviceActions": "Ações do dispositivo", "deviceActionsDescription": "Gerenciar status e acesso do dispositivo", "devicePendingApprovalBannerDescription": "Este dispositivo está pendente de aprovação. Não será possível conectar-se a recursos até ser aprovado.", "connected": "Conectado", "disconnected": "Desconectado", "approvalsEmptyStateTitle": "Aprovações do dispositivo não habilitado", "approvalsEmptyStateDescription": "Habilitar aprovações do dispositivo para cargos que exigem aprovação do administrador antes que os usuários possam conectar novos dispositivos.", "approvalsEmptyStateStep1Title": "Ir para Funções", "approvalsEmptyStateStep1Description": "Navegue até as configurações dos papéis da sua organização para configurar as aprovações de dispositivo.", "approvalsEmptyStateStep2Title": "Habilitar Aprovações do Dispositivo", "approvalsEmptyStateStep2Description": "Editar uma função e habilitar a opção 'Exigir aprovação de dispositivos'. Usuários com essa função precisarão de aprovação de administrador para novos dispositivos.", "approvalsEmptyStatePreviewDescription": "Pré-visualização: Quando ativado, solicitações de dispositivo pendentes aparecerão aqui para revisão", "approvalsEmptyStateButtonText": "Gerir Funções" } ================================================ FILE: messages/ru-RU.json ================================================ { "setupCreate": "Создать организацию, сайт и ресурсы", "headerAuthCompatibilityInfo": "Включите это, чтобы принудительно вернуть ответ 401 Unauthorized, если отсутствует токен аутентификации. Это требуется для браузеров или определенных библиотек HTTP, которые не отправляют учетные данные без запроса сервера.", "headerAuthCompatibility": "Дополнительная совместимость", "setupNewOrg": "Новая организация", "setupCreateOrg": "Создать организацию", "setupCreateResources": "Создать ресурсы", "setupOrgName": "Название организации", "orgDisplayName": "Отображаемое имя организации.", "orgId": "ID организации", "setupIdentifierMessage": "Это уникальный идентификатор для организации.", "setupErrorIdentifier": "ID организации уже занят. Выберите другой.", "componentsErrorNoMemberCreate": "Вы пока не состоите ни в одной организации. Создайте организацию для начала работы.", "componentsErrorNoMember": "Вы пока не состоите ни в одной организации.", "welcome": "Добро пожаловать!", "welcomeTo": "Добро пожаловать в", "componentsCreateOrg": "Создать организацию", "componentsMember": "Вы состоите в {count, plural, =0 {0 организациях} one {# организации} few {# организациях} many {# организациях} other {# организациях}}.", "componentsInvalidKey": "Обнаружены недействительные или просроченные лицензионные ключи. Соблюдайте условия лицензии для использования всех функций.", "dismiss": "Отменить", "subscriptionViolationMessage": "Вы превысили лимиты для вашего текущего плана. Исправьте проблему, удалив сайты, пользователей или другие ресурсы, чтобы остаться в пределах вашего плана.", "subscriptionViolationViewBilling": "Просмотр биллинга", "componentsLicenseViolation": "Нарушение лицензии: Сервер использует {usedSites} сайтов, что превышает лицензионный лимит в {maxSites} сайтов. Соблюдайте условия лицензии для использования всех функций.", "componentsSupporterMessage": "Спасибо за поддержку Pangolin в качестве {tier}!", "inviteErrorNotValid": "Извините, но это приглашение не было принято или срок его действия истёк.", "inviteErrorUser": "Извините, но приглашение, к которому вы пытаетесь получить доступ, предназначено не для этого пользователя.", "inviteLoginUser": "Убедитесь, что вы вошли под правильным пользователем.", "inviteErrorNoUser": "Извините, но похоже, что приглашение, к которому вы пытаетесь получить доступ, предназначено для несуществующего пользователя.", "inviteCreateUser": "Сначала создайте аккаунт.", "goHome": "На главную", "inviteLogInOtherUser": "Войти под другим пользователем", "createAnAccount": "Создать учётную запись", "inviteNotAccepted": "Приглашение не принято", "authCreateAccount": "Создайте учётную запись для начала работы", "authNoAccount": "Нет учётной записи?", "email": "Email", "password": "Пароль", "confirmPassword": "Подтвердите пароль", "createAccount": "Создать учётную запись", "viewSettings": "Просмотреть настройки", "delete": "Удалить", "name": "Имя", "online": "Онлайн", "offline": "Офлайн", "site": "Сайт", "dataIn": "Входящий трафик", "dataOut": "Исходящий трафик", "connectionType": "Тип соединения", "tunnelType": "Тип туннеля", "local": "Локальный", "edit": "Редактировать", "siteConfirmDelete": "Подтвердить удаление сайта", "siteDelete": "Удалить сайт", "siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.", "siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?", "siteManageSites": "Управление сайтами", "siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям", "sitesBannerTitle": "Подключить любую сеть", "sitesBannerDescription": "Сайт — это соединение с удаленной сетью, которое позволяет Pangolin предоставлять доступ к ресурсам, будь они общедоступными или частными, пользователям в любом месте. Установите сетевой коннектор сайта (Newt) там, где можно запустить исполняемый файл или контейнер, чтобы установить соединение.", "sitesBannerButtonText": "Установить сайт", "approvalsBannerTitle": "Одобрить или запретить доступ к устройству", "approvalsBannerDescription": "Просмотрите и подтвердите или отклоните запросы на доступ к устройству от пользователей. Когда требуется подтверждение устройства, пользователи должны получить одобрение администратора, прежде чем их устройства смогут подключиться к ресурсам вашей организации.", "approvalsBannerButtonText": "Узнать больше", "siteCreate": "Создать сайт", "siteCreateDescription2": "Следуйте инструкциям ниже для создания и подключения нового сайта", "siteCreateDescription": "Создайте новый сайт для начала подключения ресурсов", "close": "Закрыть", "siteErrorCreate": "Ошибка при создании сайта", "siteErrorCreateKeyPair": "Пара ключей или настройки сайта по умолчанию не найдены", "siteErrorCreateDefaults": "Настройки сайта по умолчанию не найдены", "method": "Метод", "siteMethodDescription": "Это способ, которым вы будете открывать соединения.", "siteLearnNewt": "Узнайте, как установить Newt в вашей системе", "siteSeeConfigOnce": "Вы сможете увидеть конфигурацию только один раз.", "siteLoadWGConfig": "Загрузка конфигурации WireGuard...", "siteDocker": "Развернуть для просмотра деталей развертывания Docker", "toggle": "Переключить", "dockerCompose": "Docker Compose", "dockerRun": "Docker Run", "siteLearnLocal": "Локальные сайты не создают туннели, узнать больше", "siteConfirmCopy": "Я скопировал(а) конфигурацию", "searchSitesProgress": "Поиск сайтов...", "siteAdd": "Добавить сайт", "siteInstallNewt": "Установить Newt", "siteInstallNewtDescription": "Запустите Newt в вашей системе", "WgConfiguration": "Конфигурация WireGuard", "WgConfigurationDescription": "Используйте следующую конфигурацию для подключения к сети", "operatingSystem": "Операционная система", "commands": "Команды", "recommended": "Рекомендуется", "siteNewtDescription": "Для лучшего пользовательского опыта используйте Newt. Он использует WireGuard под капотом и позволяет обращаться к вашим приватным ресурсам по их LAN-адресу в вашей частной сети прямо из панели управления Pangolin.", "siteRunsInDocker": "Работает в Docker", "siteRunsInShell": "Работает в оболочке на macOS, Linux и Windows", "siteErrorDelete": "Ошибка при удалении сайта", "siteErrorUpdate": "Не удалось обновить сайт", "siteErrorUpdateDescription": "Произошла ошибка при обновлении сайта.", "siteUpdated": "Сайт обновлён", "siteUpdatedDescription": "Сайт был успешно обновлён.", "siteGeneralDescription": "Настройте общие параметры для этого сайта", "siteSettingDescription": "Настройка параметров на сайте", "siteSetting": "Настройки {siteName}", "siteNewtTunnel": "Новый сайт (рекомендуется)", "siteNewtTunnelDescription": "Самый простой способ создать точку входа в любую сеть. Дополнительная настройка не требуется.", "siteWg": "Базовый WireGuard", "siteWgDescription": "Используйте любой клиент WireGuard для открытия туннеля. Требуется ручная настройка NAT.", "siteWgDescriptionSaas": "Используйте любой клиент WireGuard для создания туннеля. Требуется ручная настройка NAT. РАБОТАЕТ ТОЛЬКО НА САМОСТОЯТЕЛЬНО РАЗМЕЩЕННЫХ УЗЛАХ", "siteLocalDescription": "Только локальные ресурсы. Без туннелирования.", "siteLocalDescriptionSaas": "Только локальные ресурсы. Нет туннелей. Только для удаленных узлов.", "siteSeeAll": "Просмотреть все сайты", "siteTunnelDescription": "Определите, как вы хотите подключиться к сайту", "siteNewtCredentials": "Полномочия", "siteNewtCredentialsDescription": "Вот как сайт будет аутентифицироваться с сервером", "remoteNodeCredentialsDescription": "Так удалённый узел будет выполнять аутентификацию на сервере", "siteCredentialsSave": "Сохранить учетные данные", "siteCredentialsSaveDescription": "Вы сможете увидеть эти данные только один раз. Обязательно скопируйте их в безопасное место.", "siteInfo": "Информация о сайте", "status": "Статус", "shareTitle": "Управление общими ссылками", "shareDescription": "Создавайте общие ссылки, чтобы предоставить временный или постоянный доступ к прокси ресурсам", "shareSearch": "Поиск общих ссылок...", "shareCreate": "Создать общую ссылку", "shareErrorDelete": "Не удалось удалить ссылку", "shareErrorDeleteMessage": "Произошла ошибка при удалении ссылки", "shareDeleted": "Ссылка удалена", "shareDeletedDescription": "Ссылка была успешно удалена", "shareTokenDescription": "Токен доступа может быть передан двумя способами: как параметр запроса или в заголовках запроса. Они должны быть переданы от клиента по каждому запросу для аутентифицированного доступа.", "accessToken": "Токен доступа", "usageExamples": "Примеры использования", "tokenId": "ID токена", "requestHeades": "Заголовки запроса", "queryParameter": "Параметр запроса", "importantNote": "Важное примечание", "shareImportantDescription": "Из соображений безопасности рекомендуется использовать заголовки вместо параметров запроса, когда это возможно, так как параметры запроса могут сохраняться в логах сервера или истории браузера.", "token": "Токен", "shareTokenSecurety": "Храните токен доступа в безопасном режиме. Не делитесь им в общедоступных областях или на клиентской стороне.", "shareErrorFetchResource": "Не удалось получить ресурсы", "shareErrorFetchResourceDescription": "Произошла ошибка при получении ресурсов", "shareErrorCreate": "Не удалось создать общую ссылку", "shareErrorCreateDescription": "Произошла ошибка при создании общей ссылки", "shareCreateDescription": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу", "shareTitleOptional": "Заголовок (необязательно)", "expireIn": "Срок действия", "neverExpire": "Бессрочный доступ", "shareExpireDescription": "Срок действия - это период, в течение которого ссылка будет работать и предоставлять доступ к ресурсу. После этого времени ссылка перестанет работать, и пользователи, использовавшие эту ссылку, потеряют доступ к ресурсу.", "shareSeeOnce": "Вы сможете увидеть эту ссылку только один раз. Обязательно скопируйте ее.", "shareAccessHint": "Любой, у кого есть эта ссылка, может получить доступ к ресурсу. Делитесь ею с осторожностью.", "shareTokenUsage": "Посмотреть использование токена доступа", "createLink": "Создать ссылку", "resourcesNotFound": "Ресурсы не найдены", "resourceSearch": "Поиск ресурсов", "openMenu": "Открыть меню", "resource": "Ресурс", "title": "Заголовок", "created": "Создан", "expires": "Истекает", "never": "Никогда", "shareErrorSelectResource": "Пожалуйста, выберите ресурс", "proxyResourceTitle": "Управление публичными ресурсами", "proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер", "proxyResourcesBannerTitle": "Общедоступный доступ через веб", "proxyResourcesBannerDescription": "Общедоступные ресурсы — это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.", "clientResourceTitle": "Управление приватными ресурсами", "clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент", "privateResourcesBannerTitle": "Частный доступ с нулевым доверием", "privateResourcesBannerDescription": "Частные ресурсы используют безопасность с нулевым доверием, обеспечивая доступ пользователей и устройств только к ресурсам, к которым вы явно предоставили доступ. Подключите пользовательские устройства или машинных клиентов, чтобы получить доступ к этим ресурсам через безопасную виртуальную частную сеть.", "resourcesSearch": "Поиск ресурсов...", "resourceAdd": "Добавить ресурс", "resourceErrorDelte": "Ошибка при удалении ресурса", "authentication": "Аутентификация", "protected": "Защищён", "notProtected": "Не защищён", "resourceMessageRemove": "После удаления ресурс больше не будет доступен. Все целевые узлы, связанные с ресурсом, также будут удалены.", "resourceQuestionRemove": "Вы уверены, что хотите удалить ресурс из организации?", "resourceHTTP": "HTTPS-ресурс", "resourceHTTPDescription": "Проксировать запросы через HTTPS с использованием полного доменного имени.", "resourceRaw": "Сырой TCP/UDP-ресурс", "resourceRawDescription": "Проксировать запросы по сырому TCP/UDP с использованием номера порта.", "resourceRawDescriptionCloud": "Прокси-запросы через необработанный TCP/UDP с использованием номера порта. ТРЕБУЕТЕСЬ ИСПОЛЬЗОВАТЬ НЕОБХОДИМЫ.", "resourceCreate": "Создание ресурса", "resourceCreateDescription": "Следуйте инструкциям ниже для создания нового ресурса", "resourceSeeAll": "Посмотреть все ресурсы", "resourceInfo": "Информация о ресурсе", "resourceNameDescription": "Отображаемое имя ресурса.", "siteSelect": "Выберите сайт", "siteSearch": "Поиск сайта", "siteNotFound": "Сайт не найден.", "selectCountry": "Выберите страну", "searchCountries": "Поиск стран...", "noCountryFound": "Страна не найдена.", "siteSelectionDescription": "Этот сайт предоставит подключение к цели.", "resourceType": "Тип ресурса", "resourceTypeDescription": "Определить как получить доступ к ресурсу", "resourceHTTPSSettings": "Настройки HTTPS", "resourceHTTPSSettingsDescription": "Настройка доступа к ресурсу по HTTPS", "domainType": "Тип домена", "subdomain": "Поддомен", "baseDomain": "Базовый домен", "subdomnainDescription": "Поддомен, в котором ресурс будет доступен.", "resourceRawSettings": "Настройки TCP/UDP", "resourceRawSettingsDescription": "Настройка доступа к ресурсу по TCP/UDP", "protocol": "Протокол", "protocolSelect": "Выберите протокол", "resourcePortNumber": "Номер порта", "resourcePortNumberDescription": "Внешний номер порта для проксирования запросов.", "back": "Назад", "cancel": "Отмена", "resourceConfig": "Фрагменты конфигурации", "resourceConfigDescription": "Скопируйте и вставьте эти сниппеты для настройки TCP/UDP ресурса", "resourceAddEntrypoints": "Traefik: Добавить точки входа", "resourceExposePorts": "Gerbil: Открыть порты в Docker Compose", "resourceLearnRaw": "Узнайте, как настроить TCP/UDP-ресурсы", "resourceBack": "Назад к ресурсам", "resourceGoTo": "Перейти к ресурсу", "resourceDelete": "Удалить ресурс", "resourceDeleteConfirm": "Подтвердить удаление", "visibility": "Видимость", "enabled": "Включено", "disabled": "Отключено", "general": "Общие", "generalSettings": "Общие настройки", "proxy": "Прокси", "internal": "Внутренний", "rules": "Правила", "resourceSettingDescription": "Настройка параметров ресурса", "resourceSetting": "Настройки {resourceName}", "alwaysAllow": "Авторизация байпасса", "alwaysDeny": "Блокировать доступ", "passToAuth": "Переход к аутентификации", "orgSettingsDescription": "Настроить настройки организации", "orgGeneralSettings": "Настройки организации", "orgGeneralSettingsDescription": "Управление деталями и конфигурацией организации", "saveGeneralSettings": "Сохранить общие настройки", "saveSettings": "Сохранить настройки", "orgDangerZone": "Опасная зона", "orgDangerZoneDescription": "Будьте осторожны: удалив организацию, вы не сможете восстановить её.", "orgDelete": "Удалить организацию", "orgDeleteConfirm": "Подтвердить удаление", "orgMessageRemove": "Это действие необратимо и удалит все связанные данные.", "orgMessageConfirm": "Для подтверждения введите название организации ниже.", "orgQuestionRemove": "Вы уверены, что хотите удалить организацию?", "orgUpdated": "Организация обновлена", "orgUpdatedDescription": "Организация была успешно обновлена.", "orgErrorUpdate": "Не удалось обновить организацию", "orgErrorUpdateMessage": "Произошла ошибка при обновлении организации.", "orgErrorFetch": "Не удалось получить организации", "orgErrorFetchMessage": "Произошла ошибка при получении списка ваших организаций", "orgErrorDelete": "Не удалось удалить организацию", "orgErrorDeleteMessage": "Произошла ошибка при удалении организации.", "orgDeleted": "Организация удалена", "orgDeletedMessage": "Организация и её данные были удалены.", "deleteAccount": "Удалить аккаунт", "deleteAccountDescription": "Окончательно удалить учетную запись, все организации, которые вы владеете, и все данные этих организаций не могут быть отменены.", "deleteAccountButton": "Удалить аккаунт", "deleteAccountConfirmTitle": "Удалить аккаунт", "deleteAccountConfirmMessage": "Это очистит ваш аккаунт, все организации, которым вы владеете, и все данные этих организаций не могут быть отменены.", "deleteAccountConfirmString": "удалить аккаунт", "deleteAccountSuccess": "Учетная запись удалена", "deleteAccountSuccessMessage": "Ваша учетная запись удалена.", "deleteAccountError": "Не удалось удалить аккаунт", "deleteAccountPreviewAccount": "Ваша учетная запись", "deleteAccountPreviewOrgs": "Организации, которые вы владеете (и все их данные)", "orgMissing": "Отсутствует ID организации", "orgMissingMessage": "Невозможно восстановить приглашение без ID организации.", "accessUsersManage": "Управление пользователями", "accessUsersDescription": "Пригласить и управлять пользователями с доступом к этой организации", "accessUsersSearch": "Поиск пользователей...", "accessUserCreate": "Создать пользователя", "accessUserRemove": "Удалить пользователя", "username": "Имя пользователя", "identityProvider": "Поставщик удостоверений", "role": "Роль", "nameRequired": "Имя обязательно", "accessRolesManage": "Управление ролями", "accessRolesDescription": "Создание и управление ролями для пользователей в организации", "accessRolesSearch": "Поиск ролей...", "accessRolesAdd": "Добавить роль", "accessRoleDelete": "Удалить роль", "accessApprovalsManage": "Управление утверждениями", "accessApprovalsDescription": "Просмотр и управление утверждениями в ожидании доступа к этой организации", "description": "Описание", "inviteTitle": "Открытые приглашения", "inviteDescription": "Управление приглашениями для присоединения других пользователей к организации", "inviteSearch": "Поиск приглашений...", "minutes": "мин.", "hours": "ч.", "days": "д.", "weeks": "нед.", "months": "мес.", "years": "г.", "day": "{count, plural, one {# день} few {# дня} many {# дней} other {# дней}}", "apiKeysTitle": "Информация о ключе API", "apiKeysConfirmCopy2": "Подтверидте, что вы скопировали ключ API.", "apiKeysErrorCreate": "Ошибка при создании ключа API", "apiKeysErrorSetPermission": "Ошибка при установке разрешений", "apiKeysCreate": "Сгенерировать ключ API", "apiKeysCreateDescription": "Сгенерировать новый ключ API для организации", "apiKeysGeneralSettings": "Разрешения", "apiKeysGeneralSettingsDescription": "Определите, что может делать этот ключ API", "apiKeysList": "Новый ключ API", "apiKeysSave": "Сохранить ключ API", "apiKeysSaveDescription": "Вы сможете увидеть этот ключ только один раз. Обязательно скопируйте его в безопасное место.", "apiKeysInfo": "Ключ API:", "apiKeysConfirmCopy": "Я скопировал(а) ключ API", "generate": "Сгенерировать", "done": "Готово", "apiKeysSeeAll": "Посмотреть все ключи API", "apiKeysPermissionsErrorLoadingActions": "Ошибка загрузки действий ключа API", "apiKeysPermissionsErrorUpdate": "Ошибка установки разрешений", "apiKeysPermissionsUpdated": "Разрешения обновлены", "apiKeysPermissionsUpdatedDescription": "Разрешения были успешно обновлены.", "apiKeysPermissionsGeneralSettings": "Разрешения", "apiKeysPermissionsGeneralSettingsDescription": "Определите, что может делать этот ключ API", "apiKeysPermissionsSave": "Сохранить разрешения", "apiKeysPermissionsTitle": "Разрешения", "apiKeys": "Ключи API", "searchApiKeys": "Поиск ключей API...", "apiKeysAdd": "Сгенерировать ключ API", "apiKeysErrorDelete": "Ошибка при удалении ключа API", "apiKeysErrorDeleteMessage": "Не удалось удалить ключ API", "apiKeysQuestionRemove": "Вы уверены, что хотите удалить API ключ из организации?", "apiKeysMessageRemove": "После удаления ключ API больше сможет быть использован.", "apiKeysDeleteConfirm": "Подтвердить удаление", "apiKeysDelete": "Удаление ключа API", "apiKeysManage": "Управление ключами API", "apiKeysDescription": "Ключи API используются для аутентификации в интеграционном API", "apiKeysSettings": "Настройки {apiKeyName}", "userTitle": "Управление всеми пользователями", "userDescription": "Просмотр и управление всеми пользователями в системе", "userAbount": "Об управлении пользователями", "userAbountDescription": "В этой таблице отображаются все корневые объекты пользователей в системе. Каждый пользователь может принадлежать нескольким организациям. Удаление пользователя из организации не удаляет его корневой объект - он останется в системе. Чтобы полностью удалить пользователя из системы, вы должны удалить его корневой объект, используя действие удаления в этой таблице.", "userServer": "Пользователи сервера", "userSearch": "Поиск пользователей сервера...", "userErrorDelete": "Ошибка при удалении пользователя", "userDeleteConfirm": "Подтвердить удаление", "userDeleteServer": "Удаление пользователя с сервера", "userMessageRemove": "Пользователь будет удалён из всех организаций и полностью удалён с сервера.", "userQuestionRemove": "Вы уверены, что хотите навсегда удалить пользователя с сервера?", "licenseKey": "Лицензионный ключ", "valid": "Действителен", "numberOfSites": "Количество сайтов", "licenseKeySearch": "Поиск лицензионных ключей...", "licenseKeyAdd": "Добавить лицензионный ключ", "type": "Тип", "licenseKeyRequired": "Лицензионный ключ обязателен", "licenseTermsAgree": "Вы должны согласиться с условиями лицензии", "licenseErrorKeyLoad": "Не удалось загрузить лицензионные ключи", "licenseErrorKeyLoadDescription": "Произошла ошибка при загрузке лицензионных ключей.", "licenseErrorKeyDelete": "Не удалось удалить лицензионный ключ", "licenseErrorKeyDeleteDescription": "Произошла ошибка при удалении лицензионного ключа.", "licenseKeyDeleted": "Лицензионный ключ удалён", "licenseKeyDeletedDescription": "Лицензионный ключ был удалён.", "licenseErrorKeyActivate": "Не удалось активировать лицензионный ключ", "licenseErrorKeyActivateDescription": "Произошла ошибка при активации лицензионного ключа.", "licenseAbout": "О лицензировании", "communityEdition": "Community Edition", "licenseAboutDescription": "Это для бизнес и корпоративных пользователей, использующих Pangolin в коммерческой среде. Если вы используете Pangolin для личного использования, вы можете игнорировать этот раздел.", "licenseKeyActivated": "Лицензионный ключ активирован", "licenseKeyActivatedDescription": "Лицензионный ключ был успешно активирован.", "licenseErrorKeyRecheck": "Не удалось перепроверить лицензионные ключи", "licenseErrorKeyRecheckDescription": "Произошла ошибка при перепроверке лицензионных ключей.", "licenseErrorKeyRechecked": "Лицензионные ключи перепроверены", "licenseErrorKeyRecheckedDescription": "Все лицензионные ключи были перепроверены", "licenseActivateKey": "Активировать лицензионный ключ", "licenseActivateKeyDescription": "Введите лицензионный ключ для его активации.", "licenseActivate": "Активировать лицензию", "licenseAgreement": "Установив этот флажок, вы подтверждаете, что прочитали и согласны с условиями лицензии, соответствующими уровню, связанному с вашим лицензионным ключом.", "fossorialLicense": "Просмотреть коммерческую лицензию Fossorial и условия подписки", "licenseMessageRemove": "Это удалит лицензионный ключ и все связанные с ним разрешения.", "licenseMessageConfirm": "Для подтверждения введите лицензионный ключ ниже.", "licenseQuestionRemove": "Вы уверены, что хотите удалить лицензионный ключ?", "licenseKeyDelete": "Удалить лицензионный ключ", "licenseKeyDeleteConfirm": "Подтвердить удаление лицензионного ключа", "licenseTitle": "Управление статусом лицензии", "licenseTitleDescription": "Просмотр и управление лицензионными ключами в системе", "licenseHost": "Лицензия хоста", "licenseHostDescription": "Управление основным лицензионным ключом для хоста.", "licensedNot": "Не лицензировано", "hostId": "ID хоста", "licenseReckeckAll": "Перепроверить все ключи", "licenseSiteUsage": "Использование сайтов", "licenseSiteUsageDecsription": "Просмотр количества сайтов, использующих эту лицензию.", "licenseNoSiteLimit": "Нет ограничения на количество сайтов при использовании нелицензированного хоста.", "licensePurchase": "Приобрести лицензию", "licensePurchaseSites": "Приобрести дополнительные сайты", "licenseSitesUsedMax": "Использовано сайтов: {usedSites} из {maxSites}", "licenseSitesUsed": "{count, plural, =0 {0 сайтов} one {# сайт} few {# сайта} many {# сайтов} other {# сайтов}} в системе.", "licensePurchaseDescription": "Выберите, для скольких сайтов вы хотите {selectedMode, select, license {приобрести лицензию. Вы всегда можете добавить больше сайтов позже.} other {добавить к существующей лицензии.}}", "licenseFee": "Лицензионный сбор", "licensePriceSite": "Цена за сайт", "total": "Итого", "licenseContinuePayment": "Перейти к оплате", "pricingPage": "страница цен", "pricingPortal": "Посмотреть портал покупок", "licensePricingPage": "Для актуальных цен и скидок посетите ", "invite": "Приглашения", "inviteRegenerate": "Пересоздать приглашение", "inviteRegenerateDescription": "Отозвать предыдущее приглашение и создать новое", "inviteRemove": "Удалить приглашение", "inviteRemoveError": "Не удалось удалить приглашение", "inviteRemoveErrorDescription": "Произошла ошибка при удалении приглашения.", "inviteRemoved": "Приглашение удалено", "inviteRemovedDescription": "Приглашение для {email} было удалено.", "inviteQuestionRemove": "Вы уверены, что хотите удалить приглашение?", "inviteMessageRemove": "После удаления это приглашение больше не будет действительным. Вы всегда можете пригласить пользователя заново.", "inviteMessageConfirm": "Для подтверждения введите email адрес приглашения ниже.", "inviteQuestionRegenerate": "Вы уверены, что хотите пересоздать приглашение для {email}? Это отзовёт предыдущее приглашение.", "inviteRemoveConfirm": "Подтвердить удаление приглашения", "inviteRegenerated": "Приглашение пересоздано", "inviteSent": "Новое приглашение отправлено {email}.", "inviteSentEmail": "Отправить email уведомление пользователю", "inviteGenerate": "Новое приглашение создано для {email}.", "inviteDuplicateError": "Дублирующее приглашение", "inviteDuplicateErrorDescription": "Приглашение для этого пользователя уже существует.", "inviteRateLimitError": "Превышен лимит запросов", "inviteRateLimitErrorDescription": "Вы превысили лимит в 3 пересоздания в час. Попробуйте позже.", "inviteRegenerateError": "Не удалось пересоздать приглашение", "inviteRegenerateErrorDescription": "Произошла ошибка при пересоздании приглашения.", "inviteValidityPeriod": "Период действия", "inviteValidityPeriodSelect": "Выберите период действия", "inviteRegenerateMessage": "Приглашение было пересоздано. Пользователь должен перейти по ссылке ниже для принятия приглашения.", "inviteRegenerateButton": "Пересоздать", "expiresAt": "Истекает", "accessRoleUnknown": "Неизвестная роль", "placeholder": "Заполнитель", "userErrorOrgRemove": "Не удалось удалить пользователя", "userErrorOrgRemoveDescription": "Произошла ошибка при удалении пользователя.", "userOrgRemoved": "Пользователь удалён", "userOrgRemovedDescription": "Пользователь {email} был удалён из организации.", "userQuestionOrgRemove": "Вы уверены, что хотите удалить этого пользователя из организации?", "userMessageOrgRemove": "После удаления этот пользователь больше не будет иметь доступ к организации. Вы всегда можете пригласить его заново, но ему нужно будет снова принять приглашение.", "userRemoveOrgConfirm": "Подтвердить удаление пользователя", "userRemoveOrg": "Удалить пользователя из организации", "users": "Пользователи", "accessRoleMember": "Участник", "accessRoleOwner": "Владелец", "userConfirmed": "Подтверждён", "idpNameInternal": "Внутренний", "emailInvalid": "Неверный адрес Email", "inviteValidityDuration": "Пожалуйста, выберите продолжительность", "accessRoleSelectPlease": "Пожалуйста, выберите роль", "usernameRequired": "Имя пользователя обязательно", "idpSelectPlease": "Пожалуйста, выберите Identity Provider", "idpGenericOidc": "Обычный OAuth2/OIDC provider.", "accessRoleErrorFetch": "Не удалось получить роли", "accessRoleErrorFetchDescription": "Произошла ошибка при получении ролей", "idpErrorFetch": "Не удалось получить идентификатор провайдера", "idpErrorFetchDescription": "Произошла ошибка при получении поставщиков удостоверений", "userErrorExists": "Пользователь уже существует", "userErrorExistsDescription": "Этот пользователь уже является участником организации.", "inviteError": "Не удалось пригласить пользователя", "inviteErrorDescription": "Произошла ошибка при приглашении пользователя", "userInvited": "Пользователь приглашён", "userInvitedDescription": "Пользователь был успешно приглашён.", "userErrorCreate": "Не удалось создать пользователя", "userErrorCreateDescription": "Произошла ошибка при создании пользователя", "userCreated": "Пользователь создан", "userCreatedDescription": "Пользователь был успешно создан.", "userTypeInternal": "Внутренний пользователь", "userTypeInternalDescription": "Пригласить пользователя присоединиться к организации напрямую.", "userTypeExternal": "Внешний пользователь", "userTypeExternalDescription": "Создайте пользователя через внешний Identity Provider.", "accessUserCreateDescription": "Следуйте инструкциям ниже для создания нового пользователя", "userSeeAll": "Просмотр всех пользователей", "userTypeTitle": "Тип пользователя", "userTypeDescription": "Выберите способ создание пользователя", "userSettings": "Информация о пользователе", "userSettingsDescription": "Введите сведения о новом пользователе", "inviteEmailSent": "Отправить приглашение по Email", "inviteValid": "Действительно", "selectDuration": "Укажите срок действия", "selectResource": "Выберите ресурс", "filterByResource": "Фильтровать по ресурсам", "selectApprovalState": "Выберите состояние одобрения", "filterByApprovalState": "Фильтр по состоянию утверждения", "approvalListEmpty": "Нет утверждений", "approvalState": "Состояние одобрения", "approvalLoadMore": "Загрузить еще", "loadingApprovals": "Загрузка утверждений", "approve": "Одобрить", "approved": "Одобрено", "denied": "Отказано", "deniedApproval": "Отказано в одобрении", "all": "Все", "deny": "Запретить", "viewDetails": "Детали", "requestingNewDeviceApproval": "запросил новое устройство", "resetFilters": "Сбросить фильтры", "totalBlocked": "Запросы заблокированы Панголином", "totalRequests": "Всего запросов", "requestsByCountry": "Запросы по стране", "requestsByDay": "Запросы по дням", "blocked": "Заблокирован", "allowed": "Разрешено", "topCountries": "Лучшие страны", "accessRoleSelect": "Выберите роль", "inviteEmailSentDescription": "Email был отправлен пользователю со ссылкой доступа ниже. Он должен перейти по ссылке для принятия приглашения.", "inviteSentDescription": "Пользователь был приглашён. Он должен перейти по ссылке ниже для принятия приглашения.", "inviteExpiresIn": "Приглашение истечёт через {days, plural, one {# день} few {# дня} many {# дней} other {# дней}}.", "idpTitle": "Поставщик удостоверений", "idpSelect": "Выберите поставщика удостоверений для внешнего пользователя", "idpNotConfigured": "Поставщики удостоверений не настроены. Пожалуйста, настройте поставщика удостоверений перед созданием внешних пользователей.", "usernameUniq": "Это должно соответствовать уникальному имени пользователя, существующему в выбранном поставщике удостоверений.", "emailOptional": "Email (необязательно)", "nameOptional": "Имя (необязательно)", "accessControls": "Контроль доступа", "userDescription2": "Управление настройками этого пользователя", "accessRoleErrorAdd": "Не удалось добавить пользователя в роль", "accessRoleErrorAddDescription": "Произошла ошибка при добавлении пользователя в роль.", "userSaved": "Пользователь сохранён", "userSavedDescription": "Пользователь был обновлён.", "autoProvisioned": "Автоподбор", "autoProvisionedDescription": "Разрешить автоматическое управление этим пользователем", "accessControlsDescription": "Управляйте тем, к чему этот пользователь может получить доступ и что делать в организации", "accessControlsSubmit": "Сохранить контроль доступа", "roles": "Роли", "accessUsersRoles": "Управление пользователями и ролями", "accessUsersRolesDescription": "Пригласить пользователей и добавить их в роли для управления доступом к организации", "key": "Ключ", "createdAt": "Создано в", "proxyErrorInvalidHeader": "Неверное значение пользовательского заголовка Host. Используйте формат доменного имени или оставьте пустым для сброса пользовательского заголовка Host.", "proxyErrorTls": "Неверное имя TLS сервера. Используйте формат доменного имени или оставьте пустым для удаления имени TLS сервера.", "proxyEnableSSL": "Включить SSL", "proxyEnableSSLDescription": "Включить шифрование SSL/TLS для безопасных HTTPS соединений с целями.", "target": "Target", "configureTarget": "Настроить адресаты", "targetErrorFetch": "Не удалось получить цели", "targetErrorFetchDescription": "Произошла ошибка при получении целей", "siteErrorFetch": "Не удалось получить ресурс", "siteErrorFetchDescription": "Произошла ошибка при получении ресурса", "targetErrorDuplicate": "Дублирующая цель", "targetErrorDuplicateDescription": "Цель с такими настройками уже существует", "targetWireGuardErrorInvalidIp": "Неверный IP цели", "targetWireGuardErrorInvalidIpDescription": "IP цели должен быть в пределах подсети сайта", "targetsUpdated": "Цели обновлены", "targetsUpdatedDescription": "Цели и настройки успешно обновлены", "targetsErrorUpdate": "Не удалось обновить цели", "targetsErrorUpdateDescription": "Произошла ошибка при обновлении целей", "targetTlsUpdate": "Настройки TLS обновлены", "targetTlsUpdateDescription": "Настройки TLS успешно обновлены", "targetErrorTlsUpdate": "Не удалось обновить настройки TLS", "targetErrorTlsUpdateDescription": "Произошла ошибка при обновлении настроек TLS", "proxyUpdated": "Настройки прокси обновлены", "proxyUpdatedDescription": "Настройки прокси успешно обновлены", "proxyErrorUpdate": "Не удалось обновить настройки прокси", "proxyErrorUpdateDescription": "Произошла ошибка при обновлении настроек прокси", "targetAddr": "Хост", "targetPort": "Порт", "targetProtocol": "Протокол", "targetTlsSettings": "Конфигурация безопасного соединения", "targetTlsSettingsDescription": "Настроить параметры SSL/TLS для ресурса", "targetTlsSettingsAdvanced": "Расширенные настройки TLS", "targetTlsSni": "Имя TLS сервера", "targetTlsSniDescription": "Имя TLS сервера для использования в SNI. Оставьте пустым для использования по умолчанию.", "targetTlsSubmit": "Сохранить настройки", "targets": "Конфигурация целей", "targetsDescription": "Настроить цели на маршрут трафика в сервисы backend", "targetStickySessions": "Включить фиксированные сессии", "targetStickySessionsDescription": "Сохранять соединения на одной и той же целевой точке в течение всей сессии.", "methodSelect": "Выберите метод", "targetSubmit": "Добавить цель", "targetNoOne": "Этот ресурс не имеет никаких целей. Добавьте цель для настройки, где отправлять запросы в бэкэнд.", "targetNoOneDescription": "Добавление более одной цели выше включит балансировку нагрузки.", "targetsSubmit": "Сохранить цели", "addTarget": "Добавить цель", "targetErrorInvalidIp": "Неверный IP-адрес", "targetErrorInvalidIpDescription": "Пожалуйста, введите действительный IP адрес или имя хоста", "targetErrorInvalidPort": "Неверный порт", "targetErrorInvalidPortDescription": "Пожалуйста, введите правильный номер порта", "targetErrorNoSite": "Сайт не выбран", "targetErrorNoSiteDescription": "Пожалуйста, выберите сайт для цели", "targetCreated": "Цель создана", "targetCreatedDescription": "Цель была успешно создана", "targetErrorCreate": "Не удалось создать цель", "targetErrorCreateDescription": "Произошла ошибка при создании цели", "tlsServerName": "Имя TLS сервера", "tlsServerNameDescription": "Имя TLS сервера для SNI", "save": "Сохранить", "proxyAdditional": "Дополнительные настройки прокси", "proxyAdditionalDescription": "Настроить обработку параметров прокси ресурса", "proxyCustomHeader": "Пользовательский заголовок Host", "proxyCustomHeaderDescription": "Заголовок host для установки при проксировании запросов. Оставьте пустым для использования по умолчанию.", "proxyAdditionalSubmit": "Сохранить настройки прокси", "subnetMaskErrorInvalid": "Неверная маска подсети. Должна быть между 0 и 32.", "ipAddressErrorInvalidFormat": "Неверный формат IP адреса", "ipAddressErrorInvalidOctet": "Неверный октет IP адреса", "path": "Путь", "matchPath": "Путь матча", "ipAddressRange": "Диапазон IP", "rulesErrorFetch": "Не удалось получить правила", "rulesErrorFetchDescription": "Произошла ошибка при получении правил", "rulesErrorDuplicate": "Дублирующее правило", "rulesErrorDuplicateDescription": "Правило с такими настройками уже существует", "rulesErrorInvalidIpAddressRange": "Неверный CIDR", "rulesErrorInvalidIpAddressRangeDescription": "Пожалуйста, введите корректное значение CIDR", "rulesErrorInvalidUrl": "Неверный URL путь", "rulesErrorInvalidUrlDescription": "Пожалуйста, введите корректное значение URL пути", "rulesErrorInvalidIpAddress": "Неверный IP", "rulesErrorInvalidIpAddressDescription": "Пожалуйста, введите корректный IP адрес", "rulesErrorUpdate": "Не удалось обновить правила", "rulesErrorUpdateDescription": "Произошла ошибка при обновлении правил", "rulesUpdated": "Включить правила", "rulesUpdatedDescription": "Оценка правил была обновлена", "rulesMatchIpAddressRangeDescription": "Введите адрес в формате CIDR (например, 103.21.244.0/22)", "rulesMatchIpAddress": "Введите IP адрес (например, 103.21.244.12)", "rulesMatchUrl": "Введите URL путь или шаблон (например, /api/v1/todos или /api/v1/*)", "rulesErrorInvalidPriority": "Неверный приоритет", "rulesErrorInvalidPriorityDescription": "Пожалуйста, введите корректный приоритет", "rulesErrorDuplicatePriority": "Дублирующие приоритеты", "rulesErrorDuplicatePriorityDescription": "Пожалуйста, введите уникальные приоритеты", "ruleUpdated": "Правила обновлены", "ruleUpdatedDescription": "Правила успешно обновлены", "ruleErrorUpdate": "Операция не удалась", "ruleErrorUpdateDescription": "Произошла ошибка во время операции сохранения", "rulesPriority": "Приоритет", "rulesAction": "Действие", "rulesMatchType": "Тип совпадения", "value": "Значение", "rulesAbout": "О правилах", "rulesAboutDescription": "Правила позволяют контролировать доступ к ресурсу на основе набора критериев. Вы можете создать правила, чтобы разрешить или запретить доступ на основе IP-адреса или URL пути.", "rulesActions": "Действия", "rulesActionAlwaysAllow": "Всегда разрешать: Обойти все методы аутентификации", "rulesActionAlwaysDeny": "Всегда запрещать: Блокировать все запросы; аутентификация не может быть выполнена", "rulesActionPassToAuth": "Переход к аутентификации: Разрешить попытки методов аутентификации", "rulesMatchCriteria": "Критерии совпадения", "rulesMatchCriteriaIpAddress": "Совпадение с конкретным IP адресом", "rulesMatchCriteriaIpAddressRange": "Совпадение с диапазоном IP адресов в нотации CIDR", "rulesMatchCriteriaUrl": "Совпадение с URL путём или шаблоном", "rulesEnable": "Включить правила", "rulesEnableDescription": "Включить или отключить проверку правил для этого ресурса", "rulesResource": "Конфигурация правил ресурса", "rulesResourceDescription": "Настройка правил для контроля доступа к ресурсу", "ruleSubmit": "Добавить правило", "rulesNoOne": "Нет правил. Добавьте правило с помощью формы.", "rulesOrder": "Правила оцениваются по приоритету в возрастающем порядке.", "rulesSubmit": "Сохранить правила", "resourceErrorCreate": "Ошибка при создании ресурса", "resourceErrorCreateDescription": "Произошла ошибка при создании ресурса", "resourceErrorCreateMessage": "Ошибка создания ресурса:", "resourceErrorCreateMessageDescription": "Произошла неизвестная ошибка.", "sitesErrorFetch": "Ошибка при получении сайтов", "sitesErrorFetchDescription": "Произошла ошибка при получении сайтов", "domainsErrorFetch": "Ошибка при получении доменов", "domainsErrorFetchDescription": "Произошла ошибка при получении доменов", "none": "Нет", "unknown": "Неизвестно", "resources": "Ресурсы", "resourcesDescription": "Ресурсы - это прокси для приложений, работающих в частной сети. Создайте ресурс для любого HTTP/HTTPS или необработанной службы TCP/UDP в вашей частной сети. Каждый ресурс должен быть подключен к сайту, чтобы включить приватное и защищенное подключение через зашифрованный туннель WireGuard.", "resourcesWireGuardConnect": "Безопасное соединение с шифрованием WireGuard", "resourcesMultipleAuthenticationMethods": "Настройка нескольких методов аутентификации", "resourcesUsersRolesAccess": "Контроль доступа на основе пользователей и ролей", "resourcesErrorUpdate": "Не удалось переключить ресурс", "resourcesErrorUpdateDescription": "Произошла ошибка при обновлении ресурса", "access": "Доступ", "accessControl": "Контроль доступа", "shareLink": "Общая ссылка {resource}", "resourceSelect": "Выберите ресурс", "shareLinks": "Общие ссылки", "share": "Общие ссылки", "shareDescription2": "Создавайте общие ссылки на ресурсы. Ссылки обеспечивают временный или неограниченный доступ к вашему ресурсу. Вы можете настроить продолжительность действия ссылки при ее создании.", "shareEasyCreate": "Легко создавать и делиться", "shareConfigurableExpirationDuration": "Настраиваемая продолжительность истечения", "shareSecureAndRevocable": "Безопасные и отзываемые", "nameMin": "Имя должно быть не менее {len} символов.", "nameMax": "Имя не должно быть длиннее {len} символов.", "sitesConfirmCopy": "Пожалуйста, подтвердите, что вы скопировали конфигурацию.", "unknownCommand": "Неизвестная команда", "newtErrorFetchReleases": "Не удалось получить информацию о релизе: {err}", "newtErrorFetchLatest": "Ошибка при получении последнего релиза: {err}", "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "Секретный ключ", "architecture": "Архитектура", "sites": "Сайты", "siteWgAnyClients": "Для подключения используйте любой клиент WireGuard. Вы должны будете адресовать внутренние ресурсы, используя IP адрес пира.", "siteWgCompatibleAllClients": "Совместим со всеми клиентами WireGuard", "siteWgManualConfigurationRequired": "Требуется ручная настройка", "userErrorNotAdminOrOwner": "Пользователь не является администратором или владельцем", "pangolinSettings": "Настройки - Pangolin", "accessRoleYour": "Ваша роль:", "accessRoleSelect2": "Выберите роли", "accessUserSelect": "Выберите пользователей", "otpEmailEnter": "Введите email", "otpEmailEnterDescription": "Нажмите enter для добавления email после ввода в поле.", "otpEmailErrorInvalid": "Неверный email адрес. Подстановочный знак (*) должен быть всей локальной частью.", "otpEmailSmtpRequired": "Требуется SMTP", "otpEmailSmtpRequiredDescription": "SMTP должен быть включён на сервере для использования аутентификации с одноразовым паролем.", "otpEmailTitle": "Одноразовые пароли", "otpEmailTitleDescription": "Требовать аутентификацию на основе email для доступа к ресурсу", "otpEmailWhitelist": "Белый список email", "otpEmailWhitelistList": "Email адреса в белом списке", "otpEmailWhitelistListDescription": "Только пользователи с этими email адресами смогут получить доступ к этому ресурсу. Им будет предложено ввести одноразовый пароль, отправленный на их email. Можно использовать подстановочные знаки (*@example.com) для разрешения любого email адреса с домена.", "otpEmailWhitelistSave": "Сохранить белый список", "passwordAdd": "Добавить пароль", "passwordRemove": "Удалить пароль", "pincodeAdd": "Добавить PIN-код", "pincodeRemove": "Удалить PIN-код", "resourceAuthMethods": "Методы аутентификации", "resourceAuthMethodsDescriptions": "Разрешить доступ к ресурсу через дополнительные методы аутентификации", "resourceAuthSettingsSave": "Успешно сохранено", "resourceAuthSettingsSaveDescription": "Настройки аутентификации сохранены", "resourceErrorAuthFetch": "Не удалось получить данные", "resourceErrorAuthFetchDescription": "Произошла ошибка при получении данных", "resourceErrorPasswordRemove": "Ошибка при удалении пароля ресурса", "resourceErrorPasswordRemoveDescription": "Произошла ошибка при удалении пароля ресурса", "resourceErrorPasswordSetup": "Ошибка при установке пароля ресурса", "resourceErrorPasswordSetupDescription": "Произошла ошибка при установке пароля ресурса", "resourceErrorPincodeRemove": "Ошибка при удалении PIN-кода ресурса", "resourceErrorPincodeRemoveDescription": "Произошла ошибка при удалении PIN-кода ресурса", "resourceErrorPincodeSetup": "Ошибка при установке PIN-кода ресурса", "resourceErrorPincodeSetupDescription": "Произошла ошибка при установке PIN-кода ресурса", "resourceErrorUsersRolesSave": "Не удалось установить роли", "resourceErrorUsersRolesSaveDescription": "Произошла ошибка при установке ролей", "resourceErrorWhitelistSave": "Не удалось сохранить белый список", "resourceErrorWhitelistSaveDescription": "Произошла ошибка при сохранении белого списка", "resourcePasswordSubmit": "Включить защиту паролем", "resourcePasswordProtection": "Защита паролем {status}", "resourcePasswordRemove": "Пароль ресурса удалён", "resourcePasswordRemoveDescription": "Пароль ресурса был успешно удалён", "resourcePasswordSetup": "Пароль ресурса установлен", "resourcePasswordSetupDescription": "Пароль ресурса был успешно установлен", "resourcePasswordSetupTitle": "Установить пароль", "resourcePasswordSetupTitleDescription": "Установите пароль для защиты этого ресурса", "resourcePincode": "PIN-код", "resourcePincodeSubmit": "Включить защиту PIN-кодом", "resourcePincodeProtection": "Защита PIN-кодом {status}", "resourcePincodeRemove": "PIN-код ресурса удалён", "resourcePincodeRemoveDescription": "PIN-код ресурса был успешно удалён", "resourcePincodeSetup": "PIN-код ресурса установлен", "resourcePincodeSetupDescription": "PIN-код ресурса был успешно установлен", "resourcePincodeSetupTitle": "Установить PIN-код", "resourcePincodeSetupTitleDescription": "Установите PIN-код для защиты этого ресурса", "resourceRoleDescription": "Администраторы всегда имеют доступ к этому ресурсу.", "resourceUsersRoles": "Контроль доступа", "resourceUsersRolesDescription": "Выберите пользователей и роли с доступом к этому ресурсу", "resourceUsersRolesSubmit": "Сохранить контроль доступа", "resourceWhitelistSave": "Успешно сохранено", "resourceWhitelistSaveDescription": "Настройки белого списка были сохранены", "ssoUse": "Использовать Platform SSO", "ssoUseDescription": "Существующим пользователям нужно будет войти только один раз для всех ресурсов с включенной этой опцией.", "proxyErrorInvalidPort": "Неверный номер порта", "subdomainErrorInvalid": "Неверный поддомен", "domainErrorFetch": "Ошибка при получении доменов", "domainErrorFetchDescription": "Произошла ошибка при получении доменов", "resourceErrorUpdate": "Не удалось обновить ресурс", "resourceErrorUpdateDescription": "Произошла ошибка при обновлении ресурса", "resourceUpdated": "Ресурс обновлён", "resourceUpdatedDescription": "Ресурс был успешно обновлён", "resourceErrorTransfer": "Не удалось перенести ресурс", "resourceErrorTransferDescription": "Произошла ошибка при переносе ресурса", "resourceTransferred": "Ресурс перенесён", "resourceTransferredDescription": "Ресурс был успешно перенесён", "resourceErrorToggle": "Не удалось переключить ресурс", "resourceErrorToggleDescription": "Произошла ошибка при обновлении ресурса", "resourceVisibilityTitle": "Видимость", "resourceVisibilityTitleDescription": "Включите или отключите видимость ресурса", "resourceGeneral": "Общие настройки", "resourceGeneralDescription": "Настройте общие параметры этого ресурса", "resourceEnable": "Ресурс активен", "resourceTransfer": "Перенести ресурс", "resourceTransferDescription": "Перенесите этот ресурс на другой сайт", "resourceTransferSubmit": "Перенести ресурс", "siteDestination": "Новый сайт для ресурса", "searchSites": "Поиск сайтов", "countries": "Страны", "accessRoleCreate": "Создание роли", "accessRoleCreateDescription": "Создайте новую роль для группы пользователей и выдавайте им разрешения.", "accessRoleEdit": "Изменить роль", "accessRoleEditDescription": "Редактировать информацию о роли.", "accessRoleCreateSubmit": "Создать роль", "accessRoleCreated": "Роль создана", "accessRoleCreatedDescription": "Роль была успешно создана.", "accessRoleErrorCreate": "Не удалось создать роль", "accessRoleErrorCreateDescription": "Произошла ошибка при создании роли.", "accessRoleUpdateSubmit": "Обновить роль", "accessRoleUpdated": "Роль обновлена", "accessRoleUpdatedDescription": "Роль была успешно обновлена.", "accessApprovalUpdated": "Выполнено утверждение", "accessApprovalApprovedDescription": "Принять решение об утверждении запроса.", "accessApprovalDeniedDescription": "Отказано в запросе об утверждении.", "accessRoleErrorUpdate": "Не удалось обновить роль", "accessRoleErrorUpdateDescription": "Произошла ошибка при обновлении роли.", "accessApprovalErrorUpdate": "Не удалось обработать подтверждение", "accessApprovalErrorUpdateDescription": "Произошла ошибка при обработке одобрения.", "accessRoleErrorNewRequired": "Новая роль обязательна", "accessRoleErrorRemove": "Не удалось удалить роль", "accessRoleErrorRemoveDescription": "Произошла ошибка при удалении роли.", "accessRoleName": "Название роли", "accessRoleQuestionRemove": "Вы собираетесь удалить `{name}` роль. Это действие нельзя отменить.", "accessRoleRemove": "Удалить роль", "accessRoleRemoveDescription": "Удалить роль из организации", "accessRoleRemoveSubmit": "Удалить роль", "accessRoleRemoved": "Роль удалена", "accessRoleRemovedDescription": "Роль была успешно удалена.", "accessRoleRequiredRemove": "Перед удалением этой роли выберите новую роль для переноса существующих участников.", "network": "Сеть", "manage": "Управление", "sitesNotFound": "Сайты не найдены.", "pangolinServerAdmin": "Администратор сервера - Pangolin", "licenseTierProfessional": "Профессиональная лицензия", "licenseTierEnterprise": "Корпоративная лицензия", "licenseTierPersonal": "Личная лицензия", "licensed": "Лицензировано", "yes": "Да", "no": "Нет", "sitesAdditional": "Дополнительные сайты", "licenseKeys": "Лицензионные ключи", "sitestCountDecrease": "Уменьшить количество сайтов", "sitestCountIncrease": "Увеличить количество сайтов", "idpManage": "Управление поставщиками удостоверений", "idpManageDescription": "Просмотр и управление поставщиками удостоверений в системе", "idpGlobalModeBanner": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в админ-панели. Чтобы включить IdP для каждой организации, отредактируйте конфигурацию сервера и установите режим IdP в org. См. документацию. Если вы хотите продолжать использовать глобальные IdP и скрыть это из настроек организации, явно установите режим в глобальном конфиге.", "idpGlobalModeBannerUpgradeRequired": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в админ-панели. Чтобы использовать поставщиков удостоверений для каждой организации, необходимо обновить систему до версии Enterprise.", "idpGlobalModeBannerLicenseRequired": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в админ-панели. Для использования поставщиков удостоверений на организацию требуется лицензия Enterprise.", "idpDeletedDescription": "Поставщик удостоверений успешно удалён", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Вы уверены, что хотите навсегда удалить поставщика удостоверений?", "idpMessageRemove": "Это удалит поставщика удостоверений и все связанные конфигурации. Пользователи, которые аутентифицируются через этого поставщика, больше не смогут войти.", "idpMessageConfirm": "Для подтверждения введите имя поставщика удостоверений ниже.", "idpConfirmDelete": "Подтвердить удаление поставщика удостоверений", "idpDelete": "Удалить поставщика удостоверений", "idp": "Поставщики удостоверений", "idpSearch": "Поиск поставщиков удостоверений...", "idpAdd": "Добавить поставщика удостоверений", "idpClientIdRequired": "ID клиента обязателен.", "idpClientSecretRequired": "Требуется секретный пароль клиента.", "idpErrorAuthUrlInvalid": "URL авторизации должен быть корректным URL.", "idpErrorTokenUrlInvalid": "URL токена должен быть корректным URL.", "idpPathRequired": "Путь идентификатора обязателен.", "idpScopeRequired": "Области действия обязательны.", "idpOidcDescription": "Настройте поставщика удостоверений OpenID Connect", "idpCreatedDescription": "Поставщик удостоверений успешно создан", "idpCreate": "Создать поставщика удостоверений", "idpCreateDescription": "Настройте нового поставщика удостоверений для аутентификации пользователей", "idpSeeAll": "Посмотреть всех поставщиков удостоверений", "idpSettingsDescription": "Настройте базовую информацию для вашего поставщика удостоверений", "idpDisplayName": "Отображаемое имя для этого поставщика удостоверений", "idpAutoProvisionUsers": "Автоматическое создание пользователей", "idpAutoProvisionUsersDescription": "При включении пользователи будут автоматически создаваться в системе при первом входе с возможностью сопоставления пользователей с ролями и организациями.", "licenseBadge": "EE", "idpType": "Тип поставщика", "idpTypeDescription": "Выберите тип поставщика удостоверений, который вы хотите настроить", "idpOidcConfigure": "Конфигурация OAuth2/OIDC", "idpOidcConfigureDescription": "Настройте конечные точки и учётные данные поставщика OAuth2/OIDC", "idpClientId": "ID клиента", "idpClientIdDescription": "Идентификатор клиента OAuth2 от поставщика идентификации", "idpClientSecret": "Секрет клиента", "idpClientSecretDescription": "Секретный ключ клиента OAuth2 от поставщика идентификации", "idpAuthUrl": "URL авторизации", "idpAuthUrlDescription": "URL конечной точки авторизации OAuth2", "idpTokenUrl": "URL токена", "idpTokenUrlDescription": "URL конечной точки токена OAuth2", "idpOidcConfigureAlert": "Важная информация", "idpOidcConfigureAlertDescription": "После создания идентификационного провайдера вам необходимо настроить обратный адрес в настройках провайдера. URL обратного вызова будет предоставлен после успешного создания.", "idpToken": "Конфигурация токена", "idpTokenDescription": "Настройте, как извлекать информацию о пользователе из ID токена", "idpJmespathAbout": "О JMESPath", "idpJmespathAboutDescription": "Пути ниже используют синтаксис JMESPath для извлечения значений из ID токена.", "idpJmespathAboutDescriptionLink": "Узнать больше о JMESPath", "idpJmespathLabel": "Путь идентификатора", "idpJmespathLabelDescription": "Путь к идентификатору пользователя в ID токене", "idpJmespathEmailPathOptional": "Путь к email (необязательно)", "idpJmespathEmailPathOptionalDescription": "Путь к email пользователя в ID токене", "idpJmespathNamePathOptional": "Путь к имени (необязательно)", "idpJmespathNamePathOptionalDescription": "Путь к имени пользователя в ID токене", "idpOidcConfigureScopes": "Области действия", "idpOidcConfigureScopesDescription": "Список областей OAuth2, разделённых пробелами", "idpSubmit": "Создать поставщика удостоверений", "orgPolicies": "Политики организации", "idpSettings": "Настройки {idpName}", "idpCreateSettingsDescription": "Настройка параметров для идентификации провайдера", "roleMapping": "Сопоставление ролей", "orgMapping": "Сопоставление организаций", "orgPoliciesSearch": "Поиск политик организации...", "orgPoliciesAdd": "Добавить политику организации", "orgRequired": "Организация обязательна", "error": "Ошибка", "success": "Успешно", "orgPolicyAddedDescription": "Политика успешно добавлена", "orgPolicyUpdatedDescription": "Политика успешно обновлена", "orgPolicyDeletedDescription": "Политика успешно удалена", "defaultMappingsUpdatedDescription": "Сопоставления по умолчанию успешно обновлены", "orgPoliciesAbout": "О политиках организации", "orgPoliciesAboutDescription": "Политики организации используются для контроля доступа к организациям на основе ID токена пользователя. Вы можете указать выражения JMESPath для извлечения информации о роли и организации из ID токена.", "orgPoliciesAboutDescriptionLink": "См. документацию для получения дополнительной информации.", "defaultMappingsOptional": "Сопоставления по умолчанию (необязательно)", "defaultMappingsOptionalDescription": "Сопоставления по умолчанию используются, когда для организации не определена политика организации. Здесь вы можете указать сопоставления ролей и организаций по умолчанию.", "defaultMappingsRole": "Сопоставление ролей по умолчанию", "defaultMappingsRoleDescription": "Результат этого выражения должен возвращать имя роли, как определено в организации, в виде строки.", "defaultMappingsOrg": "Сопоставление организаций по умолчанию", "defaultMappingsOrgDescription": "Это выражение должно возвращать ID организации или true для разрешения доступа пользователя к организации.", "defaultMappingsSubmit": "Сохранить сопоставления по умолчанию", "orgPoliciesEdit": "Редактировать политику организации", "org": "Организация", "orgSelect": "Выберите организацию", "orgSearch": "Поиск организации", "orgNotFound": "Организация не найдена.", "roleMappingPathOptional": "Путь сопоставления ролей (необязательно)", "orgMappingPathOptional": "Путь сопоставления организаций (необязательно)", "orgPolicyUpdate": "Обновить политику", "orgPolicyAdd": "Добавить политику", "orgPolicyConfig": "Настроить доступ для организации", "idpUpdatedDescription": "Поставщик удостоверений успешно обновлён", "redirectUrl": "URL редиректа", "orgIdpRedirectUrls": "Перенаправление URL", "redirectUrlAbout": "О редиректе URL", "redirectUrlAboutDescription": "Это URL, на который пользователи будут перенаправлены после аутентификации. Вам нужно настроить этот URL в настройках провайдера.", "pangolinAuth": "Аутентификация - Pangolin", "verificationCodeLengthRequirements": "Ваш код подтверждения должен состоять из 8 символов.", "errorOccurred": "Произошла ошибка", "emailErrorVerify": "Не удалось подтвердить email:", "emailVerified": "Email успешно подтверждён! Перенаправляем вас...", "verificationCodeErrorResend": "Не удалось повторно отправить код подтверждения:", "verificationCodeResend": "Код подтверждения отправлен повторно", "verificationCodeResendDescription": "Мы повторно отправили код подтверждения на ваш email адрес. Пожалуйста, проверьте вашу почту.", "emailVerify": "Подтвердить email", "emailVerifyDescription": "Введите код подтверждения, отправленный на ваш email адрес.", "verificationCode": "Код подтверждения", "verificationCodeEmailSent": "Мы отправили код подтверждения на ваш email адрес.", "submit": "Отправить", "emailVerifyResendProgress": "Отправка повторно...", "emailVerifyResend": "Не получили код? Нажмите здесь для повторной отправки", "passwordNotMatch": "Пароли не совпадают", "signupError": "Произошла ошибка при регистрации", "pangolinLogoAlt": "Логотип Pangolin", "inviteAlready": "Похоже, вы были приглашены!", "inviteAlreadyDescription": "Чтобы принять приглашение, вы должны войти или создать учётную запись.", "signupQuestion": "Уже есть учётная запись?", "login": "Войти", "resourceNotFound": "Ресурс не найден", "resourceNotFoundDescription": "Ресурс, к которому вы пытаетесь получить доступ, не существует.", "pincodeRequirementsLength": "PIN должен состоять ровно из 6 цифр", "pincodeRequirementsChars": "PIN должен содержать только цифры", "passwordRequirementsLength": "Пароль должен быть не менее 1 символа", "passwordRequirementsTitle": "Требования к паролю:", "passwordRequirementLength": "Не менее 8 символов", "passwordRequirementUppercase": "По крайней мере, одна заглавная буква", "passwordRequirementLowercase": "По крайней мере, одна строчная буква", "passwordRequirementNumber": "По крайней мере, одна цифра", "passwordRequirementSpecial": "По крайней мере, один специальный символ", "passwordRequirementsMet": "✓ Пароль соответствует всем требованиям", "passwordStrength": "Сила пароля", "passwordStrengthWeak": "Слабый", "passwordStrengthMedium": "Средний", "passwordStrengthStrong": "Сильный", "passwordRequirements": "Требования:", "passwordRequirementLengthText": "8+ символов", "passwordRequirementUppercaseText": "Заглавная буква (A-Z)", "passwordRequirementLowercaseText": "Строчная буква (a-z)", "passwordRequirementNumberText": "Цифра (0-9)", "passwordRequirementSpecialText": "Специальный символ (!@#$%...)", "passwordsDoNotMatch": "Пароли не совпадают", "otpEmailRequirementsLength": "OTP должен быть не менее 1 символа", "otpEmailSent": "OTP отправлен", "otpEmailSentDescription": "OTP был отправлен на ваш email", "otpEmailErrorAuthenticate": "Не удалось аутентифицироваться с email", "pincodeErrorAuthenticate": "Не удалось аутентифицироваться с PIN-кодом", "passwordErrorAuthenticate": "Не удалось аутентифицироваться с паролем", "poweredBy": "Разработано", "authenticationRequired": "Требуется аутентификация", "authenticationMethodChoose": "Выберите предпочтительный метод для доступа к {name}", "authenticationRequest": "Вы должны аутентифицироваться для доступа к {name}", "user": "Пользователь", "pincodeInput": "6-значный PIN-код", "pincodeSubmit": "Войти с PIN-кодом", "passwordSubmit": "Войти с паролем", "otpEmailDescription": "Одноразовый код будет отправлен на этот email.", "otpEmailSend": "Отправить одноразовый код", "otpEmail": "Одноразовый пароль (OTP)", "otpEmailSubmit": "Отправить OTP", "backToEmail": "Назад к email", "noSupportKey": "Сервер работает без ключа поддержки. Подумайте о поддержке проекта!", "accessDenied": "Доступ запрещён", "accessDeniedDescription": "Вам не разрешён доступ к этому ресурсу. Если это ошибка, пожалуйста, свяжитесь с администратором.", "accessTokenError": "Ошибка проверки токена доступа", "accessGranted": "Доступ предоставлен", "accessUrlInvalid": "Неверный URL доступа", "accessGrantedDescription": "Вам был предоставлен доступ к этому ресурсу. Перенаправляем вас...", "accessUrlInvalidDescription": "Этот общий URL доступа недействителен. Пожалуйста, свяжитесь с владельцем ресурса для получения нового URL.", "tokenInvalid": "Неверный токен", "pincodeInvalid": "Неверный код", "passwordErrorRequestReset": "Не удалось запросить сброс:", "passwordErrorReset": "Не удалось сбросить пароль:", "passwordResetSuccess": "Пароль успешно сброшен! Вернуться к входу...", "passwordReset": "Сброс пароля", "passwordResetDescription": "Следуйте инструкциям для сброса вашего пароля", "passwordResetSent": "Мы отправим код сброса пароля на этот email адрес.", "passwordResetCode": "Код сброса пароля", "passwordResetCodeDescription": "Проверьте вашу почту для получения кода сброса пароля.", "generatePasswordResetCode": "Сгенерировать код сброса пароля", "passwordResetCodeGenerated": "Код сброса пароля создан", "passwordResetCodeGeneratedDescription": "Поделитесь этим кодом с пользователем. Они могут использовать его для сброса пароля.", "passwordResetUrl": "Reset URL", "passwordNew": "Новый пароль", "passwordNewConfirm": "Подтвердите новый пароль", "changePassword": "Изменить пароль", "changePasswordDescription": "Обновить пароль учетной записи", "oldPassword": "Текущий пароль", "newPassword": "Новый пароль", "confirmNewPassword": "Подтвердите новый пароль", "changePasswordError": "Не удалось сменить пароль", "changePasswordErrorDescription": "Произошла ошибка при смене пароля", "changePasswordSuccess": "Пароль успешно изменен", "changePasswordSuccessDescription": "Ваш пароль был успешно обновлен", "passwordExpiryRequired": "Требуется срок действия пароля", "passwordExpiryDescription": "Эта организация требует смены пароля каждые {maxDays} дней.", "changePasswordNow": "Изменить пароль сейчас", "pincodeAuth": "Код аутентификатора", "pincodeSubmit2": "Отправить код", "passwordResetSubmit": "Запросить сброс", "passwordResetAlreadyHaveCode": "Введите код", "passwordResetSmtpRequired": "Пожалуйста, обратитесь к администратору", "passwordResetSmtpRequiredDescription": "Для сброса пароля необходим код сброса пароля. Обратитесь к администратору за помощью.", "passwordBack": "Назад к паролю", "loginBack": "Вернуться на главную страницу входа", "signup": "Регистрация", "loginStart": "Войдите для начала работы", "idpOidcTokenValidating": "Проверка OIDC токена", "idpOidcTokenResponse": "Проверить ответ OIDC токена", "idpErrorOidcTokenValidating": "Ошибка проверки OIDC токена", "idpConnectingTo": "Подключение к {name}", "idpConnectingToDescription": "Проверка вашей личности", "idpConnectingToProcess": "Подключение...", "idpConnectingToFinished": "Подключено", "idpErrorConnectingTo": "Возникла проблема при подключении к {name}. Пожалуйста, свяжитесь с вашим администратором.", "idpErrorNotFound": "IdP не найден", "inviteInvalid": "Недействительное приглашение", "inviteInvalidDescription": "Ссылка на приглашение недействительна.", "inviteErrorWrongUser": "Приглашение не для этого пользователя", "inviteErrorUserNotExists": "Пользователь не существует. Пожалуйста, сначала создайте учетную запись.", "inviteErrorLoginRequired": "Вы должны войти, чтобы принять приглашение", "inviteErrorExpired": "Срок действия приглашения истек", "inviteErrorRevoked": "Возможно, приглашение было отозвано", "inviteErrorTypo": "В пригласительной ссылке может быть опечатка", "pangolinSetup": "Настройка - Pangolin", "orgNameRequired": "Название организации обязательно", "orgIdRequired": "ID организации обязателен", "orgIdMaxLength": "ID организации должен быть не более 32 символов", "orgErrorCreate": "Произошла ошибка при создании организации", "pageNotFound": "Страница не найдена", "pageNotFoundDescription": "Упс! Страница, которую вы ищете, не существует.", "overview": "Обзор", "home": "Главная", "settings": "Настройки", "usersAll": "Все пользователи", "license": "Лицензия", "pangolinDashboard": "Дашборд - Pangolin", "noResults": "Результаты не найдены.", "terabytes": "{count} ТБ", "gigabytes": "{count} ГБ", "megabytes": "{count} МБ", "tagsEntered": "Введённые теги", "tagsEnteredDescription": "Это теги, которые вы ввели.", "tagsWarnCannotBeLessThanZero": "maxTags и minTags не могут быть меньше 0", "tagsWarnNotAllowedAutocompleteOptions": "Тег не разрешён согласно опциям автозаполнения", "tagsWarnInvalid": "Недействительный тег согласно validateTag", "tagWarnTooShort": "Тег {tagText} слишком короткий", "tagWarnTooLong": "Тег {tagText} слишком длинный", "tagsWarnReachedMaxNumber": "Достигнуто максимальное количество разрешённых тегов", "tagWarnDuplicate": "Дублирующий тег {tagText} не добавлен", "supportKeyInvalid": "Недействительный ключ", "supportKeyInvalidDescription": "Ваш ключ поддержки недействителен.", "supportKeyValid": "Действительный ключ", "supportKeyValidDescription": "Ваш ключ поддержки был проверен. Спасибо за поддержку!", "supportKeyErrorValidationDescription": "Не удалось проверить ключ поддержки.", "supportKey": "Поддержите разработку и усыновите Панголина!", "supportKeyDescription": "Приобретите ключ поддержки, чтобы помочь нам продолжать разработку Pangolin для сообщества. Ваш вклад позволяет нам уделять больше времени поддержке и добавлению новых функций в приложение для всех. Мы никогда не будем использовать это для платного доступа к функциям. Это отдельно от любой коммерческой версии.", "supportKeyPet": "Вы также сможете усыновить и встретить вашего собственного питомца Панголина!", "supportKeyPurchase": "Платежи обрабатываются через GitHub. После этого вы сможете получить свой ключ на", "supportKeyPurchaseLink": "нашем сайте", "supportKeyPurchase2": "и активировать его здесь.", "supportKeyLearnMore": "Узнать больше.", "supportKeyOptions": "Пожалуйста, выберите подходящий вам вариант.", "supportKetOptionFull": "Полная поддержка", "forWholeServer": "За весь сервер", "lifetimePurchase": "Пожизненная покупка", "supporterStatus": "Статус поддержки", "buy": "Купить", "supportKeyOptionLimited": "Лимитированная поддержка", "forFiveUsers": "За 5 или меньше пользователей", "supportKeyRedeem": "Использовать ключ Поддержки", "supportKeyHideSevenDays": "Скрыть на 7 дней", "supportKeyEnter": "Введите ключ поддержки", "supportKeyEnterDescription": "Встречайте своего питомца Панголина!", "githubUsername": "Имя пользователя Github", "supportKeyInput": "Ключ поддержки", "supportKeyBuy": "Ключ поддержки", "logoutError": "Ошибка при выходе", "signingAs": "Вы вошли как", "serverAdmin": "Администратор сервера", "managedSelfhosted": "Управляемый с самовывоза", "otpEnable": "Включить Двухфакторную Аутентификацию", "otpDisable": "Отключить двухфакторную аутентификацию", "logout": "Выйти", "licenseTierProfessionalRequired": "Требуется профессиональная версия", "licenseTierProfessionalRequiredDescription": "Эта функция доступна только в профессиональной версии.", "actionGetOrg": "Получить организацию", "updateOrgUser": "Обновить пользователя Org", "createOrgUser": "Создать пользователя Org", "actionUpdateOrg": "Обновить организацию", "actionRemoveInvitation": "Удалить приглашение", "actionUpdateUser": "Обновить пользователя", "actionGetUser": "Получить пользователя", "actionGetOrgUser": "Получить пользователя организации", "actionListOrgDomains": "Список доменов организации", "actionGetDomain": "Получить домен", "actionCreateOrgDomain": "Создать домен", "actionUpdateOrgDomain": "Обновить домен", "actionDeleteOrgDomain": "Удалить домен", "actionGetDNSRecords": "Получить записи DNS", "actionRestartOrgDomain": "Перезапустить домен", "actionCreateSite": "Создать сайт", "actionDeleteSite": "Удалить сайт", "actionGetSite": "Получить сайт", "actionListSites": "Список сайтов", "actionApplyBlueprint": "Применить чертёж", "actionListBlueprints": "Список чертежей", "actionGetBlueprint": "Получить чертёж", "setupToken": "Код настройки", "setupTokenDescription": "Введите токен настройки из консоли сервера.", "setupTokenRequired": "Токен настройки обязателен", "actionUpdateSite": "Обновить сайт", "actionListSiteRoles": "Список разрешенных ролей сайта", "actionCreateResource": "Создать ресурс", "actionDeleteResource": "Удалить ресурс", "actionGetResource": "Получить ресурсы", "actionListResource": "Список ресурсов", "actionUpdateResource": "Обновить ресурс", "actionListResourceUsers": "Список пользователей ресурсов", "actionSetResourceUsers": "Список пользователей ресурсов", "actionSetAllowedResourceRoles": "Набор разрешенных ролей ресурсов", "actionListAllowedResourceRoles": "Список разрешенных ролей сайта", "actionSetResourcePassword": "Задать пароль ресурса", "actionSetResourcePincode": "Установить ПИН-код ресурса", "actionSetResourceEmailWhitelist": "Настроить белый список ресурсов email", "actionGetResourceEmailWhitelist": "Получить белый список ресурсов email", "actionCreateTarget": "Создать цель", "actionDeleteTarget": "Удалить цель", "actionGetTarget": "Получить цель", "actionListTargets": "Список целей", "actionUpdateTarget": "Обновить цель", "actionCreateRole": "Создать роль", "actionDeleteRole": "Удалить роль", "actionGetRole": "Получить Роль", "actionListRole": "Список ролей", "actionUpdateRole": "Обновить роль", "actionListAllowedRoleResources": "Список разрешенных ролей сайта", "actionInviteUser": "Пригласить пользователя", "actionRemoveUser": "Удалить пользователя", "actionListUsers": "Список пользователей", "actionAddUserRole": "Добавить роль пользователя", "actionGenerateAccessToken": "Сгенерировать токен доступа", "actionDeleteAccessToken": "Удалить токен доступа", "actionListAccessTokens": "Список токенов доступа", "actionCreateResourceRule": "Создать правило ресурса", "actionDeleteResourceRule": "Удалить правило ресурса", "actionListResourceRules": "Список правил ресурса", "actionUpdateResourceRule": "Обновить правило ресурса", "actionListOrgs": "Список организаций", "actionCheckOrgId": "Проверить ID", "actionCreateOrg": "Создать организацию", "actionDeleteOrg": "Удалить организацию", "actionListApiKeys": "Список API ключей", "actionListApiKeyActions": "Список действий API ключа", "actionSetApiKeyActions": "Установить разрешённые действия API ключа", "actionCreateApiKey": "Создать API ключ", "actionDeleteApiKey": "Удалить API ключ", "actionCreateIdp": "Создать IDP", "actionUpdateIdp": "Обновить IDP", "actionDeleteIdp": "Удалить IDP", "actionListIdps": "Список IDP", "actionGetIdp": "Получить IDP", "actionCreateIdpOrg": "Создать политику IDP организации", "actionDeleteIdpOrg": "Удалить политику IDP организации", "actionListIdpOrgs": "Список организаций IDP", "actionUpdateIdpOrg": "Обновить организацию IDP", "actionCreateClient": "Создать Клиента", "actionDeleteClient": "Удалить Клиента", "actionArchiveClient": "Архивировать клиента", "actionUnarchiveClient": "Разархивировать клиента", "actionBlockClient": "Блокировать клиента", "actionUnblockClient": "Разблокировать клиента", "actionUpdateClient": "Обновить Клиента", "actionListClients": "Список Клиентов", "actionGetClient": "Получить Клиента", "actionCreateSiteResource": "Создать ресурс сайта", "actionDeleteSiteResource": "Удалить ресурс сайта ", "actionGetSiteResource": "Получить ресурс сайта", "actionListSiteResources": "Список ресурсов сайта", "actionUpdateSiteResource": "Обновить ресурс сайта", "actionListInvitations": "Список приглашений", "actionExportLogs": "Экспорт журналов", "actionViewLogs": "Просмотр журналов", "noneSelected": "Ничего не выбрано", "orgNotFound2": "Организации не найдены.", "searchPlaceholder": "Поиск...", "emptySearchOptions": "Опции не найдены", "create": "Создать", "orgs": "Организации", "loginError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз.", "loginRequiredForDevice": "Логин необходим для вашего устройства.", "passwordForgot": "Забыли пароль?", "otpAuth": "Двухфакторная аутентификация", "otpAuthDescription": "Введите код из вашего приложения-аутентификатора или один из ваших одноразовых резервных кодов.", "otpAuthSubmit": "Отправить код", "idpContinue": "Или продолжить с", "otpAuthBack": "Назад к паролю", "navbar": "Навигационное меню", "navbarDescription": "Главное навигационное меню приложения", "navbarDocsLink": "Документация", "otpErrorEnable": "Невозможно включить 2FA", "otpErrorEnableDescription": "Произошла ошибка при включении 2FA", "otpSetupCheckCode": "Пожалуйста, введите 6-значный код", "otpSetupCheckCodeRetry": "Неверный код. Попробуйте снова.", "otpSetup": "Включить двухфакторную аутентификацию", "otpSetupDescription": "Защитите свою учётную запись дополнительным уровнем защиты", "otpSetupScanQr": "Отсканируйте этот QR-код с помощью вашего приложения-аутентификатора или введите секретный ключ вручную:", "otpSetupSecretCode": "Код аутентификатора", "otpSetupSuccess": "Двухфакторная аутентификация включена", "otpSetupSuccessStoreBackupCodes": "Ваша учётная запись теперь более защищена. Не забудьте сохранить резервные коды.", "otpErrorDisable": "Невозможно отключить 2FA", "otpErrorDisableDescription": "Произошла ошибка при отключении 2FA", "otpRemove": "Отключить двухфакторную аутентификацию", "otpRemoveDescription": "Отключить двухфакторную аутентификацию для вашей учётной записи", "otpRemoveSuccess": "Двухфакторная аутентификация отключена", "otpRemoveSuccessMessage": "Двухфакторная аутентификация была отключена для вашей учётной записи. Вы можете включить её снова в любое время.", "otpRemoveSubmit": "Отключить 2FA", "paginator": "Страница {current} из {last}", "paginatorToFirst": "Перейти на первую страницу", "paginatorToPrevious": "Перейти на предыдущую страницу", "paginatorToNext": "Перейти на следующую страницу", "paginatorToLast": "Перейти на последнюю страницу", "copyText": "Скопировать текст", "copyTextFailed": "Не удалось скопировать текст: ", "copyTextClipboard": "Копировать в буфер обмена", "inviteErrorInvalidConfirmation": "Неверное подтверждение", "passwordRequired": "Пароль обязателен", "allowAll": "Разрешить всё", "permissionsAllowAll": "Разрешить все разрешения", "githubUsernameRequired": "Имя пользователя GitHub обязательно", "supportKeyRequired": "Ключ поддержки обязателен", "passwordRequirementsChars": "Пароль должен быть не менее 8 символов", "language": "Язык", "verificationCodeRequired": "Код обязателен", "userErrorNoUpdate": "Нет пользователя для обновления", "siteErrorNoUpdate": "Нет сайта для обновления", "resourceErrorNoUpdate": "Нет ресурса для обновления", "authErrorNoUpdate": "Нет информации об аутентификации для обновления", "orgErrorNoUpdate": "Нет организации для обновления", "orgErrorNoProvided": "Организация не предоставлена", "apiKeysErrorNoUpdate": "Нет API ключа для обновления", "sidebarOverview": "Обзор", "sidebarHome": "Главная", "sidebarSites": "Сайты", "sidebarApprovals": "Запросы на утверждение", "sidebarResources": "Ресурсы", "sidebarProxyResources": "Публичный", "sidebarClientResources": "Приватный", "sidebarAccessControl": "Контроль доступа", "sidebarLogsAndAnalytics": "Журналы и аналитика", "sidebarTeam": "Команда", "sidebarUsers": "Пользователи", "sidebarAdmin": "Админ", "sidebarInvitations": "Приглашения", "sidebarRoles": "Роли", "sidebarShareableLinks": "Ссылки", "sidebarApiKeys": "API ключи", "sidebarSettings": "Настройки", "sidebarAllUsers": "Все пользователи", "sidebarIdentityProviders": "Поставщики удостоверений", "sidebarLicense": "Лицензия", "sidebarClients": "Клиенты", "sidebarUserDevices": "Устройства пользователя", "sidebarMachineClients": "Машины", "sidebarDomains": "Домены", "sidebarGeneral": "Управление", "sidebarLogAndAnalytics": "Журнал и аналитика", "sidebarBluePrints": "Чертежи", "sidebarOrganization": "Организация", "sidebarManagement": "Управление", "sidebarBillingAndLicenses": "Биллинг и лицензии", "sidebarLogsAnalytics": "Статистика", "blueprints": "Чертежи", "blueprintsDescription": "Применить декларирующие конфигурации и просмотреть предыдущие запуски", "blueprintAdd": "Добавить чертёж", "blueprintGoBack": "Посмотреть все чертежи", "blueprintCreate": "Создать чертёж", "blueprintCreateDescription2": "Для создания и применения нового чертежа выполните следующие шаги", "blueprintDetails": "Детали чертежа", "blueprintDetailsDescription": "Посмотреть результат примененного чертежа и все возникшие ошибки", "blueprintInfo": "Информация о чертеже", "message": "Сообщение", "blueprintContentsDescription": "Определите содержимое YAML, описывающее инфраструктуру", "blueprintErrorCreateDescription": "Произошла ошибка при применении чертежа", "blueprintErrorCreate": "Ошибка при создании чертежа", "searchBlueprintProgress": "Поиск чертежей...", "appliedAt": "Заявка на", "source": "Источник", "contents": "Содержание", "parsedContents": "Переработанное содержимое (только для чтения)", "enableDockerSocket": "Включить чертёж Docker", "enableDockerSocketDescription": "Включить scraping ярлыка Docker Socket для ярлыков чертежей. Путь к сокету должен быть предоставлен в Newt.", "viewDockerContainers": "Просмотр контейнеров Docker", "containersIn": "Контейнеры в {siteName}", "selectContainerDescription": "Выберите любой контейнер для использования в качестве имени хоста для этой цели. Нажмите на порт, чтобы использовать порт.", "containerName": "Имя", "containerImage": "Образ", "containerState": "Состояние", "containerNetworks": "Сети", "containerHostnameIp": "Имя хоста/IP", "containerLabels": "Метки", "containerLabelsCount": "{count, plural, one {# метка} few {# метки} many {# меток} other {# меток}}", "containerLabelsTitle": "Метки контейнера", "containerLabelEmpty": "", "containerPorts": "Порты", "containerPortsMore": "+{count} ещё", "containerActions": "Действия", "select": "Выбрать", "noContainersMatchingFilters": "Контейнеры, соответствующие текущим фильтрам, не найдены.", "showContainersWithoutPorts": "Показать контейнеры без портов", "showStoppedContainers": "Показать остановленные контейнеры", "noContainersFound": "Контейнеры не найдены. Убедитесь, что контейнеры Docker запущены.", "searchContainersPlaceholder": "Поиск среди {count} {count, plural, one {контейнера} few {контейнеров} many {контейнеров} other {контейнеров}}...", "searchResultsCount": "{count, plural, one {# результат} few {# результата} many {# результатов} other {# результатов}}", "filters": "Фильтры", "filterOptions": "Параметры фильтрации", "filterPorts": "Порты", "filterStopped": "Остановлены", "clearAllFilters": "Очистить все фильтры", "columns": "Колонки", "toggleColumns": "Переключить колонки", "refreshContainersList": "Обновить список контейнеров", "searching": "Поиск...", "noContainersFoundMatching": "Контейнеры, соответствующие \"{filter}\", не найдены.", "light": "светлая", "dark": "тёмная", "system": "системная", "theme": "Тема", "subnetRequired": "Требуется подсеть", "initialSetupTitle": "Начальная настройка сервера", "initialSetupDescription": "Создайте первоначальную учётную запись администратора сервера. Может существовать только один администратор сервера. Вы всегда можете изменить эти учётные данные позже.", "createAdminAccount": "Создать учётную запись администратора", "setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.", "certificateStatus": "Статус сертификата", "loading": "Загрузка", "loadingAnalytics": "Загрузка аналитики", "restart": "Перезагрузка", "domains": "Домены", "domainsDescription": "Создание и управление доменами, доступными в организации", "domainsSearch": "Поиск доменов...", "domainAdd": "Добавить Домен", "domainAddDescription": "Зарегистрировать новый домен в организации", "domainCreate": "Создать Домен", "domainCreatedDescription": "Домен успешно создан", "domainDeletedDescription": "Домен успешно удален", "domainQuestionRemove": "Вы уверены, что хотите удалить домен?", "domainMessageRemove": "После удаления домен больше не будет связан с организацией.", "domainConfirmDelete": "Подтвердить удаление домена", "domainDelete": "Удалить Домен", "domain": "Домен", "selectDomainTypeNsName": "Делегация домена (NS)", "selectDomainTypeNsDescription": "Этот домен и все его субдомены. Используйте это, когда вы хотите управлять всей доменной зоной.", "selectDomainTypeCnameName": "Одиночный домен (CNAME)", "selectDomainTypeCnameDescription": "Только этот конкретный домен. Используйте это для отдельных субдоменов или отдельных записей домена.", "selectDomainTypeWildcardName": "Подставной домен", "selectDomainTypeWildcardDescription": "Этот домен и его субдомены.", "domainDelegation": "Единый домен", "selectType": "Выберите тип", "actions": "Действия", "refresh": "Обновить", "refreshError": "Не удалось обновить данные", "verified": "Подтверждено", "pending": "В ожидании", "pendingApproval": "Ожидает утверждения", "sidebarBilling": "Выставление счетов", "billing": "Выставление счетов", "orgBillingDescription": "Управление платежной информацией и подписками", "github": "GitHub", "pangolinHosted": "Pangolin Hosted", "fossorial": "Fossorial", "completeAccountSetup": "Завершите настройку аккаунта", "completeAccountSetupDescription": "Установите ваш пароль, чтобы начать", "accountSetupSent": "Мы отправим код для настройки аккаунта на этот email адрес.", "accountSetupCode": "Код настройки", "accountSetupCodeDescription": "Проверьте вашу почту для получения кода настройки.", "passwordCreate": "Создать пароль", "passwordCreateConfirm": "Подтвердите пароль", "accountSetupSubmit": "Отправить код настройки", "completeSetup": "Завершить настройку", "accountSetupSuccess": "Настройка аккаунта завершена! Добро пожаловать в Pangolin!", "documentation": "Документация", "saveAllSettings": "Сохранить все настройки", "saveResourceTargets": "Сохранить цели", "saveResourceHttp": "Сохранить настройки прокси", "saveProxyProtocol": "Сохранить настройки прокси-протокола", "settingsUpdated": "Настройки обновлены", "settingsUpdatedDescription": "Настройки успешно обновлены", "settingsErrorUpdate": "Не удалось обновить настройки", "settingsErrorUpdateDescription": "Произошла ошибка при обновлении настроек", "sidebarCollapse": "Свернуть", "sidebarExpand": "Развернуть", "productUpdateMoreInfo": "{noOfUpdates} больше обновлений", "productUpdateInfo": "{noOfUpdates} обновлений", "productUpdateWhatsNew": "Что нового", "productUpdateTitle": "Обновления продуктов", "productUpdateEmpty": "Нет обновлений", "dismissAll": "Отклонить все", "pangolinUpdateAvailable": "Доступно обновление", "pangolinUpdateAvailableInfo": "Версия {version} готова к установке", "pangolinUpdateAvailableReleaseNotes": "Просмотреть примечания к выпуску", "newtUpdateAvailable": "Доступно обновление", "newtUpdateAvailableInfo": "Доступна новая версия Newt. Пожалуйста, обновитесь до последней версии для лучшего опыта.", "domainPickerEnterDomain": "Домен", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Введите полный домен ресурса, чтобы увидеть доступные опции.", "domainPickerDescriptionSaas": "Введите полный домен, поддомен или просто имя, чтобы увидеть доступные опции", "domainPickerTabAll": "Все", "domainPickerTabOrganization": "Организация", "domainPickerTabProvided": "Предоставлено", "domainPickerSortAsc": "А-Я", "domainPickerSortDesc": "Я-А", "domainPickerCheckingAvailability": "Проверка доступности...", "domainPickerNoMatchingDomains": "Подходящие домены не найдены. Попробуйте другой домен или проверьте настройки домена организации.", "domainPickerOrganizationDomains": "Домены организации", "domainPickerProvidedDomains": "Предоставленные домены", "domainPickerSubdomain": "Поддомен: {subdomain}", "domainPickerNamespace": "Пространство имен: {namespace}", "domainPickerShowMore": "Показать еще", "regionSelectorTitle": "Выберите регион", "regionSelectorInfo": "Выбор региона помогает нам обеспечить лучшее качество обслуживания для вашего расположения. Вам необязательно находиться в том же регионе, что и ваш сервер.", "regionSelectorPlaceholder": "Выбор региона", "regionSelectorComingSoon": "Скоро будет", "billingLoadingSubscription": "Загрузка подписки...", "billingFreeTier": "Бесплатный уровень", "billingWarningOverLimit": "Предупреждение: Вы превысили одну или несколько границ использования. Ваши сайты не подключатся, пока вы не измените подписку или не скорректируете использование.", "billingUsageLimitsOverview": "Обзор лимитов использования", "billingMonitorUsage": "Контролируйте использование в соответствии с установленными лимитами. Если вам требуется увеличение лимитов, пожалуйста, свяжитесь с нами support@pangolin.net.", "billingDataUsage": "Использование данных", "billingSites": "Сайты", "billingUsers": "Пользователи", "billingDomains": "Домены", "billingOrganizations": "Орги", "billingRemoteExitNodes": "Удаленные узлы", "billingNoLimitConfigured": "Лимит не установлен", "billingEstimatedPeriod": "Предполагаемый период выставления счетов", "billingIncludedUsage": "Включенное использование", "billingIncludedUsageDescription": "Использование, включенное в ваш текущий план подписки", "billingFreeTierIncludedUsage": "Бесплатное использование ограничений", "billingIncluded": "включено", "billingEstimatedTotal": "Предполагаемая сумма:", "billingNotes": "Заметки", "billingEstimateNote": "Это приблизительная оценка на основании вашего текущего использования.", "billingActualChargesMayVary": "Фактические начисления могут отличаться.", "billingBilledAtEnd": "С вас будет выставлен счет в конце периода выставления счетов.", "billingModifySubscription": "Изменить подписку", "billingStartSubscription": "Начать подписку", "billingRecurringCharge": "Периодический взнос", "billingManageSubscriptionSettings": "Управление настройками и настройками подписки", "billingNoActiveSubscription": "У вас нет активной подписки. Начните подписку, чтобы увеличить лимиты использования.", "billingFailedToLoadSubscription": "Не удалось загрузить подписку", "billingFailedToLoadUsage": "Не удалось загрузить использование", "billingFailedToGetCheckoutUrl": "Не удалось получить URL-адрес для оплаты", "billingPleaseTryAgainLater": "Пожалуйста, повторите попытку позже.", "billingCheckoutError": "Ошибка при оформлении заказа", "billingFailedToGetPortalUrl": "Не удалось получить URL-адрес портала", "billingPortalError": "Ошибка портала", "billingDataUsageInfo": "Вы несете ответственность за все данные, переданные через безопасные туннели при подключении к облаку. Это включает как входящий, так и исходящий трафик на всех ваших сайтах. При достижении лимита ваши сайты будут отключаться до тех пор, пока вы не обновите план или не уменьшите его использование. При использовании узлов не взимается плата.", "billingSInfo": "Сколько сайтов вы можете использовать", "billingUsersInfo": "Сколько пользователей вы можете использовать", "billingDomainInfo": "Сколько доменов вы можете использовать", "billingRemoteExitNodesInfo": "Сколько удаленных узлов вы можете использовать", "billingLicenseKeys": "Лицензионные ключи", "billingLicenseKeysDescription": "Управление подписками на лицензионные ключи", "billingLicenseSubscription": "Лицензионное соглашение", "billingInactive": "Неактивный", "billingLicenseItem": "Элемент лицензии", "billingQuantity": "Количество", "billingTotal": "итого", "billingModifyLicenses": "Изменить лицензию подписки", "domainNotFound": "Домен не найден", "domainNotFoundDescription": "Этот ресурс отключен, так как домен больше не существует в нашей системе. Пожалуйста, установите новый домен для этого ресурса.", "failed": "Ошибка", "createNewOrgDescription": "Создать новую организацию", "organization": "Организация", "primary": "Первичный", "port": "Порт", "securityKeyManage": "Управление ключами безопасности", "securityKeyDescription": "Добавить или удалить ключи безопасности для аутентификации без пароля", "securityKeyRegister": "Зарегистрировать новый ключ безопасности", "securityKeyList": "Ваши ключи безопасности", "securityKeyNone": "Ключи безопасности еще не зарегистрированы", "securityKeyNameRequired": "Имя обязательно", "securityKeyRemove": "Удалить", "securityKeyLastUsed": "Последнее использование: {date}", "securityKeyNameLabel": "Имя ключа безопасности", "securityKeyRegisterSuccess": "Ключ безопасности успешно зарегистрирован", "securityKeyRegisterError": "Не удалось зарегистрировать ключ безопасности", "securityKeyRemoveSuccess": "Ключ безопасности успешно удален", "securityKeyRemoveError": "Не удалось удалить ключ безопасности", "securityKeyLoadError": "Не удалось загрузить ключи безопасности", "securityKeyLogin": "Использовать ключ безопасности", "securityKeyAuthError": "Не удалось аутентифицироваться с ключом безопасности", "securityKeyRecommendation": "Зарегистрируйте резервный ключ безопасности на другом устройстве, чтобы всегда иметь доступ к вашему аккаунту.", "registering": "Регистрация...", "securityKeyPrompt": "Пожалуйста, подтвердите свою личность с использованием вашего ключа безопасности. Убедитесь, что ваш ключ безопасности подключен и готов.", "securityKeyBrowserNotSupported": "Ваш браузер не поддерживает ключи безопасности. Пожалуйста, используйте современный браузер, такой как Chrome, Firefox или Safari.", "securityKeyPermissionDenied": "Пожалуйста, разрешите доступ к вашему ключу безопасности, чтобы продолжить вход.", "securityKeyRemovedTooQuickly": "Пожалуйста, держите ваш ключ безопасности подключенным, пока процесс входа не завершится.", "securityKeyNotSupported": "Ваш ключ безопасности может быть несовместим. Попробуйте другой ключ безопасности.", "securityKeyUnknownError": "Произошла проблема при использовании вашего ключа безопасности. Пожалуйста, попробуйте еще раз.", "twoFactorRequired": "Для регистрации ключа безопасности требуется двухфакторная аутентификация.", "twoFactor": "Двухфакторная аутентификация", "twoFactorAuthentication": "Двухфакторная аутентификация", "twoFactorDescription": "Эта организация требует двухфакторной аутентификации.", "enableTwoFactor": "Включить двухфакторную аутентификацию", "organizationSecurityPolicy": "Политика безопасности Организации", "organizationSecurityPolicyDescription": "У этой организации есть требования безопасности, которые должны быть выполнены, прежде чем вы сможете получить доступ к ней", "securityRequirements": "Требования безопасности", "allRequirementsMet": "Все требования выполнены", "completeRequirementsToContinue": "Выполните следующие требования, чтобы продолжить доступ к этой организации", "youCanNowAccessOrganization": "Теперь вы можете получить доступ к этой организации", "reauthenticationRequired": "Длина сессии", "reauthenticationDescription": "Эта организация требует входа каждый {maxDays} дней.", "reauthenticationDescriptionHours": "Эта организация требует входа в систему каждый {maxHours} часов.", "reauthenticateNow": "Войти снова", "adminEnabled2FaOnYourAccount": "Ваш администратор включил двухфакторную аутентификацию для {email}. Пожалуйста, завершите процесс настройки, чтобы продолжить.", "securityKeyAdd": "Добавить ключ безопасности", "securityKeyRegisterTitle": "Регистрация нового ключа безопасности", "securityKeyRegisterDescription": "Подключите свой ключ безопасности и введите имя для его идентификации", "securityKeyTwoFactorRequired": "Требуется двухфакторная аутентификация", "securityKeyTwoFactorDescription": "Пожалуйста, введите ваш код двухфакторной аутентификации для регистрации ключа безопасности", "securityKeyTwoFactorRemoveDescription": "Пожалуйста, введите ваш код двухфакторной аутентификации для удаления ключа безопасности", "securityKeyTwoFactorCode": "Код двухфакторной аутентификации", "securityKeyRemoveTitle": "Удалить ключ безопасности", "securityKeyRemoveDescription": "Введите ваш пароль для удаления ключа безопасности \"{name}\"", "securityKeyNoKeysRegistered": "Ключи безопасности не зарегистрированы", "securityKeyNoKeysDescription": "Добавьте ключ безопасности, чтобы повысить безопасность вашего аккаунта", "createDomainRequired": "Домен обязателен", "createDomainAddDnsRecords": "Добавить DNS записи", "createDomainAddDnsRecordsDescription": "Добавьте следующие DNS записи у вашего провайдера доменных имен для завершения настройки.", "createDomainNsRecords": "NS Записи", "createDomainRecord": "Запись", "createDomainType": "Тип:", "createDomainName": "Имя:", "createDomainValue": "Значение:", "createDomainCnameRecords": "CNAME Записи", "createDomainARecords": "A Записи", "createDomainRecordNumber": "Запись {number}", "createDomainTxtRecords": "TXT Записи", "createDomainSaveTheseRecords": "Сохранить эти записи", "createDomainSaveTheseRecordsDescription": "Обязательно сохраните эти DNS записи, так как вы их больше не увидите.", "createDomainDnsPropagation": "Распространение DNS", "createDomainDnsPropagationDescription": "Изменения DNS могут занять некоторое время для распространения через интернет. Это может занять от нескольких минут до 48 часов в зависимости от вашего DNS провайдера и настроек TTL.", "resourcePortRequired": "Номер порта необходим для не-HTTP ресурсов", "resourcePortNotAllowed": "Номер порта не должен быть установлен для HTTP ресурсов", "billingPricingCalculatorLink": "Калькулятор расценок", "billingYourPlan": "Ваш план", "billingViewOrModifyPlan": "Просмотреть или изменить ваш текущий тариф", "billingViewPlanDetails": "Подробности плана", "billingUsageAndLimits": "Использование и ограничения", "billingViewUsageAndLimits": "Просмотр лимитов и текущего использования вашего плана", "billingCurrentUsage": "Текущее использование", "billingMaximumLimits": "Максимальные ограничения", "billingRemoteNodes": "Удаленные узлы", "billingUnlimited": "Неограниченный", "billingPaidLicenseKeys": "Платные лицензионные ключи", "billingManageLicenseSubscription": "Управление подпиской на платные лицензионные ключи собственного хостинга", "billingCurrentKeys": "Текущие ключи", "billingModifyCurrentPlan": "Изменить текущий план", "billingConfirmUpgrade": "Подтвердить обновление", "billingConfirmDowngrade": "Подтверждение понижения", "billingConfirmUpgradeDescription": "Вы собираетесь обновить тарифный план. Проверьте новые лимиты и цены ниже.", "billingConfirmDowngradeDescription": "Вы собираетесь понизить тарифный план. Проверьте новые ограничения и цены ниже.", "billingPlanIncludes": "Включает план", "billingProcessing": "Обработка...", "billingConfirmUpgradeButton": "Подтвердить обновление", "billingConfirmDowngradeButton": "Подтверждение понижения", "billingLimitViolationWarning": "Превышено количество новых лимитов плана", "billingLimitViolationDescription": "Ваше текущее использование превышает лимиты этого плана. После понижения значения все действия будут отключены до уменьшения использования в пределах новых лимитов. Пожалуйста, ознакомьтесь с функциями, которые в настоящее время превышают лимиты. Ограничения:", "billingFeatureLossWarning": "Уведомление о доступности функций", "billingFeatureLossDescription": "При переходе на другой тарифный план функции не будут автоматически отключены. Некоторые настройки и конфигурации могут быть потеряны. Пожалуйста, ознакомьтесь с матрицей ценообразования, чтобы понять, какие функции больше не будут доступны.", "billingUsageExceedsLimit": "Текущее использование ({current}) превышает предел ({limit})", "billingPastDueTitle": "Платеж просрочен", "billingPastDueDescription": "Ваш платеж просрочен. Пожалуйста, обновите способ оплаты, чтобы продолжить использовать текущие функции. Если ваша подписка не будет решена, она будет отменена, и вы вернетесь к бесплатному уровню.", "billingUnpaidTitle": "Подписка не оплачена", "billingUnpaidDescription": "Ваша подписка не оплачена, и вы были возвращены к бесплатному уровню. Пожалуйста, обновите способ оплаты, чтобы восстановить вашу подписку.", "billingIncompleteTitle": "Платеж не завершен", "billingIncompleteDescription": "Ваш платеж не завершен. Пожалуйста, завершите процесс оплаты, чтобы активировать вашу подписку.", "billingIncompleteExpiredTitle": "Платеж просрочен", "billingIncompleteExpiredDescription": "Ваш платеж не был завершен и истек. Вы были возвращены к бесплатному уровню. Пожалуйста, подпишитесь снова, чтобы восстановить доступ к платным функциям.", "billingManageSubscription": "Управление подпиской", "billingResolvePaymentIssue": "Пожалуйста, решите проблему оплаты перед обновлением или понижением сорта", "signUpTerms": { "IAgreeToThe": "Я согласен с", "termsOfService": "условия использования", "and": "и", "privacyPolicy": "политика конфиденциальности." }, "signUpMarketing": { "keepMeInTheLoop": "Держите меня в цикле с новостями, обновлениями и новыми функциями по электронной почте." }, "siteRequired": "Необходимо указать сайт.", "olmTunnel": "Olm Туннель", "olmTunnelDescription": "Используйте Olm для подключений клиентов", "errorCreatingClient": "Ошибка при создании клиента", "clientDefaultsNotFound": "Настройки клиента по умолчанию не найдены", "createClient": "Создать клиента", "createClientDescription": "Создайте нового клиента для доступа к приватным ресурсам", "seeAllClients": "Просмотреть всех клиентов", "clientInformation": "Информация о клиенте", "clientNamePlaceholder": "Имя клиента", "address": "Адрес", "subnetPlaceholder": "Подсеть", "addressDescription": "Внутренний адрес клиента. Должен находиться в подсети организации.", "selectSites": "Выберите сайты", "sitesDescription": "Клиент будет иметь подключение к выбранным сайтам", "clientInstallOlm": "Установить Olm", "clientInstallOlmDescription": "Запустите Olm на вашей системе", "clientOlmCredentials": "Полномочия", "clientOlmCredentialsDescription": "Именно так клиент будет аутентифицироваться с сервером", "olmEndpoint": "Endpoint", "olmId": "ID", "olmSecretKey": "Секретный ключ", "clientCredentialsSave": "Сохранить учетные данные", "clientCredentialsSaveDescription": "Вы сможете увидеть их только один раз. Обязательно скопируйте в безопасное место.", "generalSettingsDescription": "Настройте общие параметры для этого клиента", "clientUpdated": "Клиент обновлен", "clientUpdatedDescription": "Клиент был обновлён.", "clientUpdateFailed": "Не удалось обновить клиента", "clientUpdateError": "Произошла ошибка при обновлении клиента.", "sitesFetchFailed": "Не удалось получить сайты", "sitesFetchError": "Произошла ошибка при получении сайтов.", "olmErrorFetchReleases": "Произошла ошибка при получении релизов Olm.", "olmErrorFetchLatest": "Произошла ошибка при получении последнего релиза Olm.", "enterCidrRange": "Введите диапазон CIDR", "resourceEnableProxy": "Включить публичный прокси", "resourceEnableProxyDescription": "Включите публичное проксирование для этого ресурса. Это позволяет получить доступ к ресурсу извне сети через облако через открытый порт. Требуется конфигурация Traefik.", "externalProxyEnabled": "Внешний прокси включен", "addNewTarget": "Добавить новую цель", "targetsList": "Список целей", "advancedMode": "Расширенный режим", "advancedSettings": "Расширенные настройки", "targetErrorDuplicateTargetFound": "Обнаружена дублирующаяся цель", "healthCheckHealthy": "Здоровый", "healthCheckUnhealthy": "Нездоровый", "healthCheckUnknown": "Неизвестно", "healthCheck": "Проверка здоровья", "configureHealthCheck": "Настроить проверку здоровья", "configureHealthCheckDescription": "Настройте мониторинг состояния для {target}", "enableHealthChecks": "Включить проверки здоровья", "enableHealthChecksDescription": "Мониторинг здоровья этой цели. При необходимости можно контролировать другую конечную точку.", "healthScheme": "Метод", "healthSelectScheme": "Выберите метод", "healthCheckPortInvalid": "Порт проверки здоровья должен быть от 1 до 65535", "healthCheckPath": "Путь", "healthHostname": "IP / хост", "healthPort": "Порт", "healthCheckPathDescription": "Путь к проверке состояния здоровья.", "healthyIntervalSeconds": "Здоровой Интервал (сек)", "unhealthyIntervalSeconds": "Нездоровый интервал (сек)", "IntervalSeconds": "Интервал здоровых состояний", "timeoutSeconds": "Таймаут (сек)", "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 считается здоровым.", "customHeaders": "Пользовательские заголовки", "customHeadersDescription": "Заголовки новой строки, разделённые: название заголовка: значение", "headersValidationError": "Заголовки должны быть в формате: Название заголовка: значение.", "saveHealthCheck": "Сохранить проверку здоровья", "healthCheckSaved": "Проверка здоровья сохранена", "healthCheckSavedDescription": "Конфигурация проверки состояния успешно сохранена", "healthCheckError": "Ошибка проверки состояния", "healthCheckErrorDescription": "Произошла ошибка при сохранении конфигурации проверки состояния", "healthCheckPathRequired": "Требуется путь проверки состояния", "healthCheckMethodRequired": "Требуется метод HTTP", "healthCheckIntervalMin": "Интервал проверки должен составлять не менее 5 секунд", "healthCheckTimeoutMin": "Тайм-аут должен составлять не менее 1 секунды", "healthCheckRetryMin": "Количество попыток должно быть не менее 1", "httpMethod": "HTTP метод", "selectHttpMethod": "Выберите HTTP метод", "domainPickerSubdomainLabel": "Поддомен", "domainPickerBaseDomainLabel": "Основной домен", "domainPickerSearchDomains": "Поиск доменов...", "domainPickerNoDomainsFound": "Доменов не найдено", "domainPickerLoadingDomains": "Загрузка доменов...", "domainPickerSelectBaseDomain": "Выбор основного домена...", "domainPickerNotAvailableForCname": "Не доступно для CNAME доменов", "domainPickerEnterSubdomainOrLeaveBlank": "Введите поддомен или оставьте пустым для использования основного домена.", "domainPickerEnterSubdomainToSearch": "Введите поддомен для поиска и выбора из доступных свободных доменов.", "domainPickerFreeDomains": "Свободные домены", "domainPickerSearchForAvailableDomains": "Поиск доступных доменов", "domainPickerNotWorkSelfHosted": "Примечание: бесплатные предоставляемые домены в данный момент недоступны для самоуправляемых экземпляров.", "resourceDomain": "Домен", "resourceEditDomain": "Редактировать домен", "siteName": "Имя сайта", "proxyPort": "Порт", "resourcesTableProxyResources": "Публичный", "resourcesTableClientResources": "Приватный", "resourcesTableNoProxyResourcesFound": "Проксированных ресурсов не найдено.", "resourcesTableNoInternalResourcesFound": "Внутренних ресурсов не найдено.", "resourcesTableDestination": "Пункт назначения", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "Псевдоним адреса", "resourcesTableAliasAddressInfo": "Этот адрес является частью вспомогательной подсети организации. Он используется для разрешения псевдонимов с использованием внутреннего разрешения DNS.", "resourcesTableClients": "Клиенты", "resourcesTableAndOnlyAccessibleInternally": "и доступны только внутренне при подключении с клиентом.", "resourcesTableNoTargets": "Нет ярлыков", "resourcesTableHealthy": "Здоровые", "resourcesTableDegraded": "Ухудшение", "resourcesTableOffline": "Оффлайн", "resourcesTableUnknown": "Неизвестен", "resourcesTableNotMonitored": "Не отслеживается", "editInternalResourceDialogEditClientResource": "Изменить приватный ресурс", "editInternalResourceDialogUpdateResourceProperties": "Обновить настройки ресурса и элементы управления доступом для {resourceName}", "editInternalResourceDialogResourceProperties": "Свойства ресурса", "editInternalResourceDialogName": "Имя", "editInternalResourceDialogProtocol": "Протокол", "editInternalResourceDialogSitePort": "Порт сайта", "editInternalResourceDialogTargetConfiguration": "Настройка цели", "editInternalResourceDialogCancel": "Отмена", "editInternalResourceDialogSaveResource": "Сохранить ресурс", "editInternalResourceDialogSuccess": "Успешно", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Внутренний ресурс успешно обновлен", "editInternalResourceDialogError": "Ошибка", "editInternalResourceDialogFailedToUpdateInternalResource": "Не удалось обновить внутренний ресурс", "editInternalResourceDialogNameRequired": "Имя обязательно", "editInternalResourceDialogNameMaxLength": "Имя не должно быть длиннее 255 символов", "editInternalResourceDialogProxyPortMin": "Порт прокси должен быть не менее 1", "editInternalResourceDialogProxyPortMax": "Порт прокси должен быть меньше 65536", "editInternalResourceDialogInvalidIPAddressFormat": "Неверный формат IP адреса", "editInternalResourceDialogDestinationPortMin": "Целевой порт должен быть не менее 1", "editInternalResourceDialogDestinationPortMax": "Целевой порт должен быть меньше 65536", "editInternalResourceDialogPortModeRequired": "Порт для порта необходим для протокола, прокси и порта назначения", "editInternalResourceDialogMode": "Режим", "editInternalResourceDialogModePort": "Порт", "editInternalResourceDialogModeHost": "Хост", "editInternalResourceDialogModeCidr": "СИДР", "editInternalResourceDialogDestination": "Пункт назначения", "editInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.", "editInternalResourceDialogDestinationIPDescription": "IP или адрес хоста ресурса в сети сайта.", "editInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.", "createInternalResourceDialogNoSitesAvailable": "Нет доступных сайтов", "createInternalResourceDialogNoSitesAvailableDescription": "Вам необходимо иметь хотя бы один сайт Newt с настроенной подсетью для создания внутреннего ресурса.", "createInternalResourceDialogClose": "Закрыть", "createInternalResourceDialogCreateClientResource": "Создать приватный ресурс", "createInternalResourceDialogCreateClientResourceDescription": "Создать новый ресурс, который будет доступен только клиентам, подключенным к организации", "createInternalResourceDialogResourceProperties": "Свойства ресурса", "createInternalResourceDialogName": "Имя", "createInternalResourceDialogSite": "Сайт", "selectSite": "Выберите сайт...", "noSitesFound": "Сайты не найдены.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Порт сайта", "createInternalResourceDialogSitePortDescription": "Используйте этот порт для доступа к ресурсу на сайте при подключении с клиентом.", "createInternalResourceDialogTargetConfiguration": "Настройка цели", "createInternalResourceDialogDestinationIPDescription": "IP или адрес хоста ресурса в сети сайта.", "createInternalResourceDialogDestinationPortDescription": "Порт на IP-адресе назначения, где доступен ресурс.", "createInternalResourceDialogCancel": "Отмена", "createInternalResourceDialogCreateResource": "Создать ресурс", "createInternalResourceDialogSuccess": "Успешно", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Внутренний ресурс успешно создан", "createInternalResourceDialogError": "Ошибка", "createInternalResourceDialogFailedToCreateInternalResource": "Не удалось создать внутренний ресурс", "createInternalResourceDialogNameRequired": "Имя обязательно", "createInternalResourceDialogNameMaxLength": "Имя должно содержать менее 255 символов", "createInternalResourceDialogPleaseSelectSite": "Пожалуйста, выберите сайт", "createInternalResourceDialogProxyPortMin": "Прокси-порт должен быть не менее 1", "createInternalResourceDialogProxyPortMax": "Прокси-порт должен быть меньше 65536", "createInternalResourceDialogInvalidIPAddressFormat": "Неверный формат IP-адреса", "createInternalResourceDialogDestinationPortMin": "Целевой порт должен быть не менее 1", "createInternalResourceDialogDestinationPortMax": "Целевой порт должен быть меньше 65536", "createInternalResourceDialogPortModeRequired": "Порт для порта необходим для протокола, прокси и порта назначения", "createInternalResourceDialogMode": "Режим", "createInternalResourceDialogModePort": "Порт", "createInternalResourceDialogModeHost": "Хост", "createInternalResourceDialogModeCidr": "СИДР", "createInternalResourceDialogDestination": "Пункт назначения", "createInternalResourceDialogDestinationHostDescription": "IP адрес или имя хоста ресурса в сети сайта.", "createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.", "siteConfiguration": "Конфигурация", "siteAcceptClientConnections": "Принимать подключения клиентов", "siteAcceptClientConnectionsDescription": "Разрешить пользовательским устройствам и клиентам доступ к ресурсам на этом сайте. Это может быть изменено позже.", "siteAddress": "Адрес сайта (Дополнительно)", "siteAddressDescription": "Внутренний адрес сайта. Должен находиться в подсети организации.", "siteNameDescription": "Отображаемое имя сайта, которое может быть изменено позже.", "autoLoginExternalIdp": "Автоматический вход с внешним провайдером", "autoLoginExternalIdpDescription": "Немедленно перенаправьте пользователя к внешнему поставщику удостоверений для аутентификации.", "selectIdp": "Выберите провайдера", "selectIdpPlaceholder": "Выберите провайдера...", "selectIdpRequired": "Пожалуйста, выберите провайдера, когда автоматический вход включен.", "autoLoginTitle": "Перенаправление", "autoLoginDescription": "Перенаправление вас к внешнему провайдеру для аутентификации.", "autoLoginProcessing": "Подготовка аутентификации...", "autoLoginRedirecting": "Перенаправление к входу...", "autoLoginError": "Ошибка автоматического входа", "autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.", "autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.", "remoteExitNodeManageRemoteExitNodes": "Удаленные узлы", "remoteExitNodeDescription": "Самостоятельно размещайте свои удаленные ретрансляторы и узлы прокси-сервера", "remoteExitNodes": "Узлы", "searchRemoteExitNodes": "Поиск узлов...", "remoteExitNodeAdd": "Добавить узел", "remoteExitNodeErrorDelete": "Ошибка удаления узла", "remoteExitNodeQuestionRemove": "Вы уверены, что хотите удалить узел из организации?", "remoteExitNodeMessageRemove": "После удаления узел больше не будет доступен.", "remoteExitNodeConfirmDelete": "Подтвердите удаление узла", "remoteExitNodeDelete": "Удалить узел", "sidebarRemoteExitNodes": "Удаленные узлы", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "Секретный ключ", "remoteExitNodeCreate": { "title": "Создать удалённый узел", "description": "Создайте новый самостоятельный удалённый ретранслятор и узел прокси-сервера", "viewAllButton": "Все узлы", "strategy": { "title": "Стратегия создания", "description": "Выберите способ создания удалённого узла", "adopt": { "title": "Принять узел", "description": "Выберите это, если у вас уже есть учетные данные для узла." }, "generate": { "title": "Сгенерировать ключи", "description": "Выберите это, если вы хотите создать новые ключи для узла." } }, "adopt": { "title": "Принять существующий узел", "description": "Введите учетные данные существующего узла, который вы хотите принять", "nodeIdLabel": "ID узла", "nodeIdDescription": "ID существующего узла, который вы хотите принять", "secretLabel": "Секретный ключ", "secretDescription": "Секретный ключ существующего узла", "submitButton": "Принять узел" }, "generate": { "title": "Сгенерированные учетные данные", "description": "Используйте эти учётные данные для настройки узла", "nodeIdTitle": "ID узла", "secretTitle": "Секретный ключ", "saveCredentialsTitle": "Добавить учетные данные в конфигурацию", "saveCredentialsDescription": "Добавьте эти учетные данные в файл конфигурации вашего самоуправляемого узла Pangolin, чтобы завершить подключение.", "submitButton": "Создать узел" }, "validation": { "adoptRequired": "ID узла и секрет требуются при установке существующего узла" }, "errors": { "loadDefaultsFailed": "Не удалось загрузить параметры по умолчанию", "defaultsNotLoaded": "Параметры по умолчанию не загружены", "createFailed": "Не удалось создать узел" }, "success": { "created": "Узел успешно создан" } }, "remoteExitNodeSelection": "Выбор узла", "remoteExitNodeSelectionDescription": "Выберите узел для маршрутизации трафика для этого локального сайта", "remoteExitNodeRequired": "Узел должен быть выбран для локальных сайтов", "noRemoteExitNodesAvailable": "Нет доступных узлов", "noRemoteExitNodesAvailableDescription": "Для этой организации узлы не доступны. Сначала создайте узел, чтобы использовать локальные сайты.", "exitNode": "Узел выхода", "country": "Страна", "rulesMatchCountry": "В настоящее время основано на исходном IP", "managedSelfHosted": { "title": "Управляемый с самовывоза", "description": "Более надежный и низко обслуживаемый сервер Pangolin с дополнительными колокольнями и свистками", "introTitle": "Управляемый Само-Хост Панголина", "introDescription": "- это вариант развертывания, предназначенный для людей, которые хотят простоты и надёжности, сохраняя при этом свои данные конфиденциальными и самостоятельными.", "introDetail": "С помощью этой опции вы по-прежнему используете узел Pangolin — туннели, SSL, и весь остающийся на вашем сервере. Разница заключается в том, что управление и мониторинг осуществляются через нашу панель инструментов из облака, которая открывает ряд преимуществ:", "benefitSimplerOperations": { "title": "Более простые операции", "description": "Не нужно запускать свой собственный почтовый сервер или настроить комплексное оповещение. Вы будете получать проверки состояния здоровья и оповещения о неисправностях из коробки." }, "benefitAutomaticUpdates": { "title": "Автоматическое обновление", "description": "Панель управления в облаке развивается быстро, так что вы получаете новые функции и исправления ошибок, без необходимости каждый раз получать новые контейнеры." }, "benefitLessMaintenance": { "title": "Меньше обслуживания", "description": "Нет миграции баз данных, резервных копий или дополнительной инфраструктуры для управления. Мы обрабатываем это в облаке." }, "benefitCloudFailover": { "title": "Облачное срабатывание", "description": "Если ваш узел исчезнет, ваши туннели могут временно прерваться до наших облачных точек присутствия, пока вы не вернете его в сети." }, "benefitHighAvailability": { "title": "Высокая доступность (PoP)", "description": "Вы также можете прикрепить несколько узлов к вашему аккаунту для избыточности и лучшей производительности." }, "benefitFutureEnhancements": { "title": "Будущие улучшения", "description": "Мы планируем добавить дополнительные инструменты аналитики, оповещения и управления, чтобы сделать установку еще более надежной." }, "docsAlert": { "text": "Узнайте больше о опции Managed Self-Hosted в нашей", "documentation": "документация" }, "convertButton": "Конвертировать этот узел в управляемый себе-хост" }, "internationaldomaindetected": "Обнаружен международный домен", "willbestoredas": "Будет храниться как:", "roleMappingDescription": "Определите, как роли, назначаемые пользователям, когда они войдут в систему автоматического профиля.", "selectRole": "Выберите роль", "roleMappingExpression": "Выражение", "selectRolePlaceholder": "Выберите роль", "selectRoleDescription": "Выберите роль, чтобы назначить всем пользователям этого поставщика идентификации", "roleMappingExpressionDescription": "Введите выражение JMESPath, чтобы извлечь информацию о роли из ID токена", "idpTenantIdRequired": "Требуется ID владельца", "invalidValue": "Неверное значение", "idpTypeLabel": "Тип поставщика удостоверений", "roleMappingExpressionPlaceholder": "например, contains(groups, 'admin') && 'Admin' || 'Member'", "idpGoogleConfiguration": "Конфигурация Google", "idpGoogleConfigurationDescription": "Настройка учетных данных Google OAuth2", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Секрет клиента Google OAuth2", "idpAzureConfiguration": "Конфигурация Azure Entra ID", "idpAzureConfigurationDescription": "Настройка учетных данных Azure Entra ID OAuth2", "idpTenantId": "Идентификатор арендатора", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "ID арендатора Azure (найден в обзоре Active Directory Azure)", "idpAzureClientIdDescription": "Регистрационный номер клиента Azure App", "idpAzureClientSecretDescription": "Секрет регистрации клиента Azure App", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Конфигурация Google", "idpAzureConfigurationTitle": "Конфигурация Azure Entra ID", "idpTenantIdLabel": "Идентификатор арендатора", "idpAzureClientIdDescription2": "Регистрационный номер клиента Azure App", "idpAzureClientSecretDescription2": "Секрет регистрации клиента Azure App", "idpGoogleDescription": "Google OAuth2/OIDC провайдер", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Подсеть", "subnetDescription": "Подсеть для конфигурации сети этой организации.", "customDomain": "Пользовательский домен", "authPage": "Страницы аутентификации", "authPageDescription": "Установите пользовательский домен для страниц аутентификации организации", "authPageDomain": "Домен страницы авторизации", "authPageBranding": "Пользовательское брендирование", "authPageBrandingDescription": "Настройте брендирование, отображаемое на страницах аутентификации для этой организации", "authPageBrandingUpdated": "Брендирование страницы аутентификации успешно обновлено", "authPageBrandingRemoved": "Брендирование страницы аутентификации успешно удалено", "authPageBrandingRemoveTitle": "Удалить брендирование страницы аутентификации", "authPageBrandingQuestionRemove": "Вы уверены, что хотите удалить брендирование для страниц аутентификации?", "authPageBrandingDeleteConfirm": "Подтвердить удаление брендирования", "brandingLogoURL": "URL логотипа", "brandingLogoURLOrPath": "URL логотипа или путь", "brandingLogoPathDescription": "Введите URL или локальный путь.", "brandingLogoURLDescription": "Введите публичный URL для изображения вашего логотипа.", "brandingPrimaryColor": "Основной цвет", "brandingLogoWidth": "Ширина (px)", "brandingLogoHeight": "Высота (px)", "brandingOrgTitle": "Заголовок для страницы аутентификации организации", "brandingOrgDescription": "{orgName} будет заменен названием организации", "brandingOrgSubtitle": "Подзаголовок страницы аутентификации организации", "brandingResourceTitle": "Заголовок для страницы аутентификации ресурса", "brandingResourceSubtitle": "Подзаголовок страницы аутентификации ресурса", "brandingResourceDescription": "{resourceName} будет заменено на имя организации", "saveAuthPageDomain": "Сохранить домен", "saveAuthPageBranding": "Сохранить брендирование", "removeAuthPageBranding": "Удалить брендирование", "noDomainSet": "Домен не установлен", "changeDomain": "Изменить домен", "selectDomain": "Выберите домен", "restartCertificate": "Перезапустить сертификат", "editAuthPageDomain": "Редактировать домен страницы авторизации", "setAuthPageDomain": "Установить домен страницы авторизации", "failedToFetchCertificate": "Не удалось получить сертификат", "failedToRestartCertificate": "Не удалось перезапустить сертификат", "addDomainToEnableCustomAuthPages": "Пользователи смогут получить доступ к странице входа в систему организации и завершить аутентификацию ресурса, используя этот домен.", "selectDomainForOrgAuthPage": "Выберите домен для страницы аутентификации организации", "domainPickerProvidedDomain": "Домен предоставлен", "domainPickerFreeProvidedDomain": "Бесплатный домен", "domainPickerVerified": "Подтверждено", "domainPickerUnverified": "Не подтверждено", "domainPickerInvalidSubdomainStructure": "Этот поддомен содержит недопустимые символы или структуру. Он будет очищен автоматически при сохранении.", "domainPickerError": "Ошибка", "domainPickerErrorLoadDomains": "Не удалось загрузить домены организации", "domainPickerErrorCheckAvailability": "Не удалось проверить доступность домена", "domainPickerInvalidSubdomain": "Неверный поддомен", "domainPickerInvalidSubdomainRemoved": "Ввод \"{sub}\" был удален, потому что он недействителен.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" не может быть действительным для {domain}.", "domainPickerSubdomainSanitized": "Субдомен очищен", "domainPickerSubdomainCorrected": "\"{sub}\" был исправлен на \"{sanitized}\"", "orgAuthSignInTitle": "Вход в организацию", "orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения", "orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.", "orgAuthSignInWithPangolin": "Войти через Pangolin", "orgAuthSignInToOrg": "Войти в организацию", "orgAuthSelectOrgTitle": "Вход в организацию", "orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить", "orgAuthOrgIdPlaceholder": "ваша-организация", "orgAuthOrgIdHelp": "Введите уникальный идентификатор вашей организации", "orgAuthSelectOrgHelp": "После ввода ID вашей организации вы попадете на страницу входа в вашу организацию, где сможете использовать SSO или учетные данные вашей организации.", "orgAuthRememberOrgId": "Запомнить этот ID организации", "orgAuthBackToSignIn": "Вернуться к стандартному входу", "orgAuthNoAccount": "Нет учётной записи?", "subscriptionRequiredToUse": "Для использования этой функции требуется подписка.", "mustUpgradeToUse": "Вы должны обновить подписку, чтобы использовать эту функцию.", "subscriptionRequiredTierToUse": "Эта функция требует {tier} или выше.", "upgradeToTierToUse": "Обновитесь до {tier} или выше, чтобы использовать эту функцию.", "subscriptionTierTier1": "Главная", "subscriptionTierTier2": "Команда", "subscriptionTierTier3": "Бизнес", "subscriptionTierEnterprise": "Предприятие", "idpDisabled": "Провайдеры идентификации отключены.", "orgAuthPageDisabled": "Страница авторизации организации отключена.", "domainRestartedDescription": "Проверка домена успешно перезапущена", "resourceAddEntrypointsEditFile": "Редактировать файл: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Редактировать файл: docker-compose.yml", "emailVerificationRequired": "Требуется подтверждение адреса электронной почты. Пожалуйста, войдите снова через {dashboardUrl}/auth/login завершить этот шаг. Затем вернитесь сюда.", "twoFactorSetupRequired": "Требуется настройка двухфакторной аутентификации. Пожалуйста, войдите снова через {dashboardUrl}/auth/login завершить этот шаг. Затем вернитесь сюда.", "additionalSecurityRequired": "Требуется дополнительная безопасность", "organizationRequiresAdditionalSteps": "Эта организация требует дополнительных шагов безопасности, прежде чем вы сможете получить доступ к ресурсам.", "completeTheseSteps": "Выполните эти шаги", "enableTwoFactorAuthentication": "Включить двухфакторную аутентификацию", "completeSecuritySteps": "Пройти шаги безопасности", "securitySettings": "Настройки безопасности", "dangerSection": "Опасная зона", "dangerSectionDescription": "Навсегда удалить все данные, связанные с этой организацией", "securitySettingsDescription": "Настройка политик безопасности для организации", "requireTwoFactorForAllUsers": "Требовать двухфакторную аутентификацию для всех пользователей", "requireTwoFactorDescription": "Когда включено, все внутренние пользователи в этой организации должны иметь двухфакторную аутентификацию для доступа к организации.", "requireTwoFactorDisabledDescription": "Эта функция требует действительной лицензии (Enterprise) или активной подписки (SaaS)", "requireTwoFactorCannotEnableDescription": "Вы должны включить двухфакторную аутентификацию для вашей учетной записи, прежде чем принудительно ее применять для всех пользователей", "maxSessionLength": "Максимальная длина сессии", "maxSessionLengthDescription": "Установите максимальную длительность сессий пользователя. После этого времени, пользователям нужно будет пройти повторную аутентификацию.", "maxSessionLengthDisabledDescription": "Эта функция требует действительной лицензии (Enterprise) или активной подписки (SaaS)", "selectSessionLength": "Выберите длину сеанса", "unenforced": "Не применено", "1Hour": "1 час", "3Hours": "3 часа", "6Hours": "6 часов", "12Hours": "12 часов", "1DaySession": "1 день", "3Days": "3 дня", "7Days": "7 дней", "14Days": "14 дней", "30DaysSession": "30 дней", "90DaysSession": "90 дней", "180DaysSession": "180 дней", "passwordExpiryDays": "Срок действия пароля", "editPasswordExpiryDescription": "Установите количество дней, прежде чем пользователи должны изменить свой пароль.", "selectPasswordExpiry": "Выберите срок действия пароля", "30Days": "30 дней", "1Day": "1 день", "60Days": "60 дней", "90Days": "90 дней", "180Days": "180 дней", "1Year": "1 год", "subscriptionBadge": "Требуется подписка", "securityPolicyChangeWarning": "Предупреждение об изменении политики безопасности", "securityPolicyChangeDescription": "Вы собираетесь изменить настройки политики безопасности. После сохранения вам может потребоваться повторная аутентификация, чтобы соответствовать этим обновлениям. Все пользователи, которые не соответствуют установленным правилам, также должны пройти процедуру повторной аутентификации.", "securityPolicyChangeConfirmMessage": "Подтверждаю", "securityPolicyChangeWarningText": "Это повлияет на всех пользователей организации", "authPageErrorUpdateMessage": "Произошла ошибка при обновлении настроек страницы авторизации", "authPageErrorUpdate": "Не удалось обновить страницу авторизации", "authPageDomainUpdated": "Домен страницы аутентификации успешно обновлён", "healthCheckNotAvailable": "Локальный", "rewritePath": "Переписать путь", "rewritePathDescription": "При необходимости, измените путь перед пересылкой к целевому адресу.", "continueToApplication": "Перейти к приложению", "checkingInvite": "Проверка приглашения", "setResourceHeaderAuth": "установить заголовок ресурса", "resourceHeaderAuthRemove": "Удалить проверку подлинности заголовка", "resourceHeaderAuthRemoveDescription": "Проверка подлинности заголовка успешно удалена.", "resourceErrorHeaderAuthRemove": "Не удалось удалить аутентификацию заголовка", "resourceErrorHeaderAuthRemoveDescription": "Не удалось удалить проверку подлинности заголовка ресурса.", "resourceHeaderAuthProtectionEnabled": "Заголовок аутентификации включен", "resourceHeaderAuthProtectionDisabled": "Проверка подлинности заголовка отключена", "headerAuthRemove": "Удалить проверку подлинности заголовка", "headerAuthAdd": "Добавить заголовок аутентификации", "resourceErrorHeaderAuthSetup": "Не удалось установить аутентификацию заголовка", "resourceErrorHeaderAuthSetupDescription": "Не удалось установить проверку подлинности заголовка ресурса.", "resourceHeaderAuthSetup": "Проверка подлинности заголовка успешно установлена", "resourceHeaderAuthSetupDescription": "Проверка подлинности заголовка успешно установлена.", "resourceHeaderAuthSetupTitle": "Установить проверку подлинности заголовка", "resourceHeaderAuthSetupTitleDescription": "Установите основные учетные данные авторизации (имя пользователя и пароль), чтобы защитить этот ресурс с помощью заголовка HTTP. Получите доступ к нему с помощью формата https://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Установить проверку подлинности заголовка", "actionSetResourceHeaderAuth": "Установить проверку подлинности заголовка", "enterpriseEdition": "Корпоративная версия", "unlicensed": "Нелицензированный", "beta": "Бета", "manageUserDevices": "Устройства пользователя", "manageUserDevicesDescription": "Просмотр и управление устройствами, которые пользователи используют для приватного подключения к ресурсам", "downloadClientBannerTitle": "Скачать клиент Pangolin", "downloadClientBannerDescription": "Загрузите клиент Pangolin для вашей системы, чтобы подключиться к сети Pangolin и получить доступ к ресурсам в частном порядке.", "manageMachineClients": "Управление машинными клиентами", "manageMachineClientsDescription": "Создание и управление клиентами, которые используют серверы и системы для частного подключения к ресурсам", "machineClientsBannerTitle": "Серверы и автоматизированные системы", "machineClientsBannerDescription": "Клиенты для машин предназначены для серверов и автоматизированных систем, которые не связаны с конкретным пользователем. Они аутентифицируются по ID и секрету и могут работать с Pangolin CLI, Olm CLI или Olm как с контейнером.", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Olm как контейнер", "clientsTableUserClients": "Пользователь", "clientsTableMachineClients": "Машина", "licenseTableValidUntil": "Действителен до", "saasLicenseKeysSettingsTitle": "Корпоративные лицензии", "saasLicenseKeysSettingsDescription": "Генерировать и управлять лицензионными ключами Enterprise для копий Pangolin", "sidebarEnterpriseLicenses": "Лицензии", "generateLicenseKey": "Сгенерировать лицензионный ключ", "generateLicenseKeyForm": { "validation": { "emailRequired": "Пожалуйста, введите действительный адрес электронной почты", "useCaseTypeRequired": "Пожалуйста, выберите тип варианта использования", "firstNameRequired": "Требуется имя", "lastNameRequired": "Требуется фамилия", "primaryUseRequired": "Пожалуйста, опишите ваше основное использование", "jobTitleRequiredBusiness": "Должность требуется для коммерческого использования", "industryRequiredBusiness": "Промышленность необходима для коммерческого использования", "stateProvinceRegionRequired": "Регион/Область обязательно", "postalZipCodeRequired": "Почтовый индекс требуется", "companyNameRequiredBusiness": "Название компании обязательно для бизнес-использования", "countryOfResidenceRequiredBusiness": "Страна проживания необходима для коммерческого использования", "countryRequiredPersonal": "Страна необходима для личного использования", "agreeToTermsRequired": "Вы должны принять условия", "complianceConfirmationRequired": "Вы должны подтвердить соответствие с Fossorial Commercial License" }, "useCaseOptions": { "personal": { "title": "Личное использование", "description": "Для индивидуального, некоммерческого использования, например, обучения, личных проектов или экспериментов." }, "business": { "title": "Бизнес-использование", "description": "Для использования в организациях, компаниях или коммерческих или приносящих доход видах деятельности." } }, "steps": { "emailLicenseType": { "title": "Email и тип лицензии", "description": "Введите адрес электронной почты и выберите тип лицензии" }, "personalInformation": { "title": "Личная информация", "description": "Расскажите нам о себе" }, "contactInformation": { "title": "Контактная информация", "description": "Ваши контактные данные" }, "termsGenerate": { "title": "Условия и Сгенерировать", "description": "Просмотрите и примите условия для создания вашей лицензии" } }, "alerts": { "commercialUseDisclosure": { "title": "Раскрытие", "description": "Выберите уровень лицензии, который точно отражает ваше предполагаемое использование. Личная Лицензия разрешает свободное использование Программного Обеспечения для частной, некоммерческой или малой коммерческой деятельности с годовым валовым доходом до $100 000 USD. Любое использование сверх этих пределов — включая использование в бизнесе, организацию, или другой приносящей доход среде — требует действительной лицензии предприятия и уплаты соответствующей лицензионной платы. Все пользователи, будь то Личные или Предприятия, обязаны соблюдать условия коммерческой лицензии Fossoral." }, "trialPeriodInformation": { "title": "Информация о пробном периоде", "description": "Этот лицензионный ключ позволяет корпоративным функциям на 7-дневный период оценки. Для продолжения доступа к платным функциям за пределами ознакомительного периода требуется активация в рамках действующей лицензии Личного или Предприятия. Для получения лицензии свяжитесь с sales@pangolin.net." } }, "form": { "useCaseQuestion": "Вы используете Pangolin для личного или делового использования?", "firstName": "First Name", "lastName": "Фамилия", "jobTitle": "Заголовок", "primaryUseQuestion": "Что вы планируете использовать Панголин в первую очередь?", "industryQuestion": "Какая у вас отрасль?", "prospectiveUsersQuestion": "Сколько у вас потенциальных пользователей?", "prospectiveSitesQuestion": "Сколько потенциальных сайтов (туннелей) вы ожидаете?", "companyName": "Название компании", "countryOfResidence": "Страна проживания", "stateProvinceRegion": "Область / регион", "postalZipCode": "Почтовый индекс", "companyWebsite": "Веб-сайт компании", "companyPhoneNumber": "Телефон компании", "country": "Страна", "phoneNumberOptional": "Номер телефона (необязательно)", "complianceConfirmation": "Я подтверждаю, что информация, которую я предоставляю, является точной и что я в соответствии с коммерческой лицензии Fossorial. Сообщение о неточной информации или неправильно идентифицирующем использовании продукта является нарушением лицензии и может привести к аннулированию вашего ключа." }, "buttons": { "close": "Закрыть", "previous": "Предыдущий", "next": "Следующий", "generateLicenseKey": "Сгенерировать лицензионный ключ" }, "toasts": { "success": { "title": "Лицензионный ключ успешно создан", "description": "Ваш лицензионный ключ сгенерирован и готов к использованию." }, "error": { "title": "Не удалось сгенерировать лицензионный ключ", "description": "Произошла ошибка при генерации лицензионного ключа." } } }, "newPricingLicenseForm": { "title": "Получить лицензию", "description": "Выберите план и расскажите нам, как вы планируете использовать Панголин.", "chooseTier": "Выберите ваш план", "viewPricingLink": "Смотрите цены, возможности и ограничения", "tiers": { "starter": { "title": "Старт", "description": "Функции предприятия, 25 пользователей, 25 сайтов, и поддержка сообщества." }, "scale": { "title": "Масштаб", "description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка." } }, "personalUseOnly": "Только для личного пользования (бесплатная лицензия — без оформления)", "buttons": { "continueToCheckout": "Продолжить оформление заказа" }, "toasts": { "checkoutError": { "title": "Ошибка оформления заказа", "description": "Не удалось начать оформление заказа. Пожалуйста, попробуйте еще раз." } } }, "priority": "Приоритет", "priorityDescription": "Маршруты с более высоким приоритетом оцениваются первым. Приоритет = 100 означает автоматическое упорядочение (решение системы). Используйте другой номер для обеспечения ручного приоритета.", "instanceName": "Имя экземпляра", "pathMatchModalTitle": "Настроить соответствие пути", "pathMatchModalDescription": "Настройка соответствия входящих запросов на основе их пути.", "pathMatchType": "Тип совпадения", "pathMatchPrefix": "Префикс", "pathMatchExact": "Точно", "pathMatchRegex": "Регенерация", "pathMatchValue": "Значение пути", "clear": "Очистить", "saveChanges": "Сохранить изменения", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/путь", "pathMatchPrefixHelp": "Пример: /api matches /api, /api/users, etc.", "pathMatchExactHelp": "Пример: /api соответствует только /api", "pathMatchRegexHelp": "Пример: ^/api/.* совпадает с /api/anything", "pathRewriteModalTitle": "Настроить перезапись пути", "pathRewriteModalDescription": "Преобразовать соответствующий путь перед пересылкой к цели.", "pathRewriteType": "Тип перезаписи", "pathRewritePrefixOption": "Префикс - Замена префикса", "pathRewriteExactOption": "Точно - Заменить весь путь", "pathRewriteRegexOption": "Regex - замена шаблона", "pathRewriteStripPrefixOption": "Префикс вырезать - Удалить префикс", "pathRewriteValue": "Перезаписать значение", "pathRewriteRegexPlaceholder": "/new/$1", "pathRewriteDefaultPlaceholder": "/new-path", "pathRewritePrefixHelp": "Заменить соответствующий префикс этим значением", "pathRewriteExactHelp": "Замените весь путь этим значением, когда путь точно соответствует", "pathRewriteRegexHelp": "Использовать группы захвата типа $1, $2 для замены", "pathRewriteStripPrefixHelp": "Оставьте пустым для префикса полосы или укажите новый префикс", "pathRewritePrefix": "Префикс", "pathRewriteExact": "Точно", "pathRewriteRegex": "Регенерация", "pathRewriteStrip": "Вырезать", "pathRewriteStripLabel": "полоса", "sidebarEnableEnterpriseLicense": "Включить корпоративную лицензию", "cannotbeUndone": "Это действие не может быть отменено.", "toConfirm": "для подтверждения.", "deleteClientQuestion": "Вы уверены, что хотите удалить клиента из сайта и организации?", "clientMessageRemove": "После удаления клиент больше не сможет подключиться к сайту.", "sidebarLogs": "Логи", "request": "Запросить", "requests": "Запросы", "logs": "Логи", "logsSettingsDescription": "Мониторинг журналов, собранных от этой организации", "searchLogs": "Поиск журналов...", "action": "Действие", "actor": "Актер", "timestamp": "Отметка времени", "accessLogs": "Журналы доступа", "exportCsv": "Экспорт CSV", "exportError": "Неизвестная ошибка при экспорте CSV", "exportCsvTooltip": "В пределах диапазона времени", "actorId": "ID актера", "allowedByRule": "Разрешено правилом", "allowedNoAuth": "Разрешено без авторизации", "validAccessToken": "Действительный маркер доступа", "validHeaderAuth": "Valid header auth", "validPincode": "Valid Pincode", "validPassword": "Допустимый пароль", "validEmail": "Valid email", "validSSO": "Valid SSO", "resourceBlocked": "Ресурс заблокирован", "droppedByRule": "Отброшено по правилам", "noSessions": "Нет сессий", "temporaryRequestToken": "Временный токен запроса", "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "Причина", "requestLogs": "Запросить журналы", "requestAnalytics": "Аналитика запроса", "host": "Хост", "location": "Местоположение", "actionLogs": "Журнал действий", "sidebarLogsRequest": "Запросить журналы", "sidebarLogsAccess": "Журналы доступа", "sidebarLogsAction": "Журнал действий", "logRetention": "Сохранение журнала", "logRetentionDescription": "Управление сохранением различных типов журналов для этой организации или отключение их", "requestLogsDescription": "Просмотреть подробные журналы запроса ресурсов в этой организации", "requestAnalyticsDescription": "Просмотреть подробную аналитику запроса для ресурсов в этой организации", "logRetentionRequestLabel": "Запросить сохранение журнала", "logRetentionRequestDescription": "Как долго сохранять журналы запросов", "logRetentionAccessLabel": "Хранение журнала доступа", "logRetentionAccessDescription": "Как долго сохранять журналы доступа", "logRetentionActionLabel": "Сохранение журнала действий", "logRetentionActionDescription": "Как долго хранить журналы действий", "logRetentionDisabled": "Отключено", "logRetention3Days": "3 дня", "logRetention7Days": "7 дней", "logRetention14Days": "14 дней", "logRetention30Days": "30 дней", "logRetention90Days": "90 дней", "logRetentionForever": "Всегда", "logRetentionEndOfFollowingYear": "Конец следующего года", "actionLogsDescription": "Просмотр истории действий, выполненных в этой организации", "accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации", "licenseRequiredToUse": "Лицензия на Enterprise Edition требуется для использования этой функции. Эта функция также доступна в Pangolin Cloud.", "ossEnterpriseEditionRequired": "Для использования этой функции требуется Enterprise Edition. Эта функция также доступна в Pangolin Cloud.", "certResolver": "Резольвер сертификата", "certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.", "selectCertResolver": "Выберите резолвер сертификата", "enterCustomResolver": "Введите пользовательский резолвер", "preferWildcardCert": "Предпочитать сертификат Wildcard", "unverified": "Не подтверждено", "domainSetting": "Настройки домена", "domainSettingDescription": "Настройка параметров домена", "preferWildcardCertDescription": "Попытка создать сертификат с подстановочными знаками (требуется правильно настроенное средство разрешения сертификатов).", "recordName": "Имя записи", "auto": "Авто", "TTL": "TTL", "howToAddRecords": "Как добавить записи", "dnsRecord": "DNS записи", "required": "Требуется", "domainSettingsUpdated": "Настройки домена успешно обновлены", "orgOrDomainIdMissing": "Отсутствует организация или ID домена", "loadingDNSRecords": "Загрузка записей DNS...", "olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.", "client": "Клиент", "proxyProtocol": "Настройки протокола прокси", "proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.", "enableProxyProtocol": "Включить Прокси Протокол", "proxyProtocolInfo": "Сохранять IP-адреса клиента для backend'ов TCP", "proxyProtocolVersion": "Версия протокола прокси", "version1": " Версия 1 (рекомендуется)", "version2": "Версия 2", "versionDescription": "Версия 1 основана на тексте и широко поддерживается. Версия 2 является бинарной и более эффективной, но менее совместимой.", "warning": "Предупреждение", "proxyProtocolWarning": "Бэкэнд приложение должно быть настроено на принятие соединений прокси-протокола. Если ваш бэкэнд не поддерживает Прокси-протокол, то включение этой опции прервет все подключения, поэтому включите это только если вы знаете, что вы делаете. Обязательно настройте вашего бэкэнда на доверие заголовкам Proxy Protocol от Traefik.", "restarting": "Перезапуск...", "manual": "Ручной", "messageSupport": "Поддержка сообщений", "supportNotAvailableTitle": "Поддержка недоступна", "supportNotAvailableDescription": "Поддержка сейчас недоступна. Вы можете отправить письмо по адресу support@pangolin.net.", "supportRequestSentTitle": "Запрос на поддержку отправлен", "supportRequestSentDescription": "Ваше сообщение успешно отправлено.", "supportRequestFailedTitle": "Не удалось отправить запрос", "supportRequestFailedDescription": "Произошла ошибка при отправке запроса поддержки.", "supportSubjectRequired": "Необходимо ввести тему", "supportSubjectMaxLength": "Тема должна быть 255 символов или меньше", "supportMessageRequired": "Требуется сообщение", "supportReplyTo": "Ответить", "supportSubject": "Тема", "supportSubjectPlaceholder": "Введите тему", "supportMessage": "Сообщение", "supportMessagePlaceholder": "Введите ваше сообщение", "supportSending": "Отправка...", "supportSend": "Отправить", "supportMessageSent": "Сообщение отправлено!", "supportWillContact": "Мы скоро свяжемся с Вами!", "selectLogRetention": "Выберите удержание журнала", "terms": "Условия", "privacy": "Приватность", "security": "Безопасность", "docs": "Документ", "deviceActivation": "Активация устройства", "deviceCodeInvalidFormat": "Код должен быть 9 символов (например, A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Неверный или просроченный код", "deviceCodeVerifyFailed": "Не удалось проверить код устройства", "deviceCodeValidating": "Проверка кода устройства...", "deviceCodeVerifying": "Проверка авторизации устройства...", "signedInAs": "Вы вошли как", "deviceCodeEnterPrompt": "Введите код, отображаемый на устройстве", "continue": "Продолжить", "deviceUnknownLocation": "Неизвестное местоположение", "deviceAuthorizationRequested": "Эта авторизация была запрошена у {location} на {date}. Убедитесь, что вы доверяете этому устройству, так как оно получит доступ к учетной записи.", "deviceLabel": "Устройство: {deviceName}", "deviceWantsAccess": "хочет получить доступ к вашей учетной записи", "deviceExistingAccess": "Существующий доступ:", "deviceFullAccess": "Полный доступ к вашему аккаунту", "deviceOrganizationsAccess": "Доступ ко всем организациям, к которым ваш аккаунт имеет доступ", "deviceAuthorize": "Авторизовать {applicationName}", "deviceConnected": "Устройство подключено!", "deviceAuthorizedMessage": "Устройство авторизовано для доступа к вашей учетной записи. Вернитесь в клиентское приложение.", "pangolinCloud": "Облако Панголина", "viewDevices": "Просмотр устройств", "viewDevicesDescription": "Управление подключенными устройствами", "noDevices": "Устройств не найдено", "dateCreated": "Дата создания", "unnamedDevice": "Безымянное устройство", "deviceQuestionRemove": "Вы уверены, что хотите удалить это устройство?", "deviceMessageRemove": "Это действие нельзя отменить.", "deviceDeleteConfirm": "Удалить устройство", "deleteDevice": "Удалить устройство", "errorLoadingDevices": "Ошибка загрузки устройств", "failedToLoadDevices": "Не удалось загрузить устройства", "deviceDeleted": "Устройство удалено", "deviceDeletedDescription": "Устройство успешно удалено.", "errorDeletingDevice": "Ошибка удаления устройства", "failedToDeleteDevice": "Не удалось удалить устройство", "showColumns": "Показать колонки", "hideColumns": "Скрыть столбцы", "columnVisibility": "Видимость столбцов", "toggleColumn": "Столбец {columnName}", "allColumns": "Все колонки", "defaultColumns": "Столбцы по умолчанию", "customizeView": "Настроить вид", "viewOptions": "Параметры просмотра", "selectAll": "Выделить все", "selectNone": "Не выбирать", "selectedResources": "Выбранные ресурсы", "enableSelected": "Включить выбранные", "disableSelected": "Отключить выбранные", "checkSelectedStatus": "Проверить статус выбранных", "clients": "Клиенты", "accessClientSelect": "Выберите машинные клиенты", "resourceClientDescription": "Машинные клиенты, которые имеют доступ к этому ресурсу", "regenerate": "Пересоздать", "credentials": "Полномочия", "savecredentials": "Сохранить учетные данные", "regenerateCredentialsButton": "Пересоздать учетные данные", "regenerateCredentials": "Пересоздать учетные данные", "generatedcredentials": "Сгенерированные учетные данные", "copyandsavethesecredentials": "Копировать и сохранить эти учетные данные", "copyandsavethesecredentialsdescription": "Эти учетные данные не будут отображаться снова после того, как вы покинете эту страницу. Сохраните их сейчас.", "credentialsSaved": "Учетные данные сохранены", "credentialsSavedDescription": "Учетные данные были успешно восстановлены и сохранены.", "credentialsSaveError": "Ошибка сохранения учетных данных", "credentialsSaveErrorDescription": "Произошла ошибка при восстановлении и сохранении учетных данных.", "regenerateCredentialsWarning": "Восстановление учётных данных приведет к аннулированию предыдущих учетных данных и отключению соединения. Убедитесь, что все конфигурации, использующие эти учетные данные.", "confirm": "Подтвердить", "regenerateCredentialsConfirmation": "Вы уверены, что хотите восстановить учетные данные?", "endpoint": "Endpoint", "Id": "Id", "SecretKey": "Секретный ключ", "niceId": "Неплохой ID", "niceIdUpdated": "Хороший ID обновлен", "niceIdUpdatedSuccessfully": "Неплохой ID успешно обновлен", "niceIdUpdateError": "Ошибка обновления Nice ID", "niceIdUpdateErrorDescription": "Произошла ошибка при обновлении Nice ID.", "niceIdCannotBeEmpty": "Неправильный ID не может быть пустым", "enterIdentifier": "Введите идентификатор", "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Не вы? Используйте другую учетную запись.", "deviceLoginDeviceRequestingAccessToAccount": "Устройство запрашивает доступ к этой учетной записи.", "loginSelectAuthenticationMethod": "Выберите метод аутентификации для продолжения.", "noData": "Нет данных", "machineClients": "Машинные клиенты", "install": "Установить", "run": "Запустить", "clientNameDescription": "Отображаемое имя клиента, которое может быть изменено позже.", "clientAddress": "Адрес клиента (Дополнительно)", "setupFailedToFetchSubnet": "Не удалось получить подсеть по умолчанию", "setupSubnetAdvanced": "Подсеть (Дополнительно)", "setupSubnetDescription": "Подсеть для внутренней сети этой организации.", "setupUtilitySubnet": "Утилита подсети (расширенная)", "setupUtilitySubnetDescription": "Подсеть для адресов псевдонимов этой организации и DNS-сервера.", "siteRegenerateAndDisconnect": "Сгенерировать и отключить", "siteRegenerateAndDisconnectConfirmation": "Вы уверены, что хотите сгенерировать учетные данные и отключить этот сайт?", "siteRegenerateAndDisconnectWarning": "Это позволит восстановить учетные данные и немедленно отключить сайт. Сайт будет перезапущен с новыми учетными данными.", "siteRegenerateCredentialsConfirmation": "Вы уверены, что хотите восстановить учетные данные для этого сайта?", "siteRegenerateCredentialsWarning": "Это позволит восстановить учетные данные. Сайт будет оставаться подключенным, пока вы не перезапустите его вручную и используйте новые учетные данные.", "clientRegenerateAndDisconnect": "Сгенерировать и отключить", "clientRegenerateAndDisconnectConfirmation": "Вы уверены, что хотите восстановить учетные данные и отключить этого клиента?", "clientRegenerateAndDisconnectWarning": "Это позволит восстановить учетные данные и немедленно отключить клиент. Клиент будет перезапущен с новыми учетными данными.", "clientRegenerateCredentialsConfirmation": "Вы уверены, что хотите сгенерировать данные для этого клиента?", "clientRegenerateCredentialsWarning": "Это позволит восстановить учетные данные. Клиент останется подключенным, пока вы не перезапустите его вручную и воспользуетесь новыми учетными данными.", "remoteExitNodeRegenerateAndDisconnect": "Сгенерировать и отключить", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Вы уверены, что хотите сгенерировать учетные данные и отключить этот удаленный узел?", "remoteExitNodeRegenerateAndDisconnectWarning": "Это позволит восстановить учётные данные и немедленно отключить удаленный узел выхода. Удаленный узел выхода должен быть перезапущен с новыми учетными данными.", "remoteExitNodeRegenerateCredentialsConfirmation": "Вы уверены, что хотите восстановить учетные данные для этого удаленного выхода узла?", "remoteExitNodeRegenerateCredentialsWarning": "Это позволит восстановить учетные данные. Удалённый узел останется подключенным, пока вы не перезапустите его вручную и воспользуетесь новыми учетными данными.", "agent": "Агент", "personalUseOnly": "Только для личного использования", "loginPageLicenseWatermark": "Это экземпляр лицензирован только для личного использования.", "instanceIsUnlicensed": "Этот экземпляр не лицензирован.", "portRestrictions": "Ограничения портов", "allPorts": "Все", "custom": "Пользовательский", "allPortsAllowed": "Все порты разрешены", "allPortsBlocked": "Все порты заблокированы", "tcpPortsDescription": "Укажите, какие TCP-порты разрешены для этого ресурса. Используйте '*' для всех портов, оставьте пустым, чтобы заблокировать все, или введите список портов и диапазонов через запятую (например, 80,443,8000-9000).", "udpPortsDescription": "Укажите, какие UDP-порты разрешены для этого ресурса. Используйте '*' для всех портов, оставьте пустым, чтобы заблокировать все, или введите список портов и диапазонов через запятую (например, 53,123,500-600).", "organizationLoginPageTitle": "Страница входа в систему организации", "organizationLoginPageDescription": "Настройте страницу входа для этой организации", "resourceLoginPageTitle": "Страница входа в систему ресурса", "resourceLoginPageDescription": "Настройте страницу входа для отдельных ресурсов", "enterConfirmation": "Введите подтверждение", "blueprintViewDetails": "Подробности", "defaultIdentityProvider": "Поставщик удостоверений по умолчанию", "defaultIdentityProviderDescription": "Когда выбран поставщик идентификации по умолчанию, пользователь будет автоматически перенаправлен на провайдер для аутентификации.", "editInternalResourceDialogNetworkSettings": "Настройки сети", "editInternalResourceDialogAccessPolicy": "Политика доступа", "editInternalResourceDialogAddRoles": "Добавить роли", "editInternalResourceDialogAddUsers": "Добавить пользователей", "editInternalResourceDialogAddClients": "Добавить клиентов", "editInternalResourceDialogDestinationLabel": "Пункт назначения", "editInternalResourceDialogDestinationDescription": "Укажите адрес назначения для внутреннего ресурса. Это может быть имя хоста, IP-адрес или диапазон CIDR в зависимости от выбранного режима. При необходимости установите внутренний DNS-алиас для облегчения идентификации.", "editInternalResourceDialogPortRestrictionsDescription": "Ограничьте доступ к определенным TCP/UDP-портам или разрешите/заблокируйте все порты.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Контроль доступа", "editInternalResourceDialogAccessControlDescription": "Контролируйте, какие роли, пользователи и машинные клиенты имеют доступ к этому ресурсу при подключении. Администраторы всегда имеют доступ.", "editInternalResourceDialogPortRangeValidationError": "Диапазон портов должен быть \"*\" для всех портов или списком портов и диапазонов через запятую (например, \"80,443,8000-9000\"). Порты должны находиться в диапазоне от 1 до 65535.", "internalResourceAuthDaemonStrategy": "Местоположение демона по SSH", "internalResourceAuthDaemonStrategyDescription": "Выберите, где работает демон аутентификации SSH: на сайте (Newt) или на удаленном узле.", "internalResourceAuthDaemonDescription": "Демон аутентификации SSH обрабатывает подписание ключей SSH и аутентификацию PAM для этого ресурса. Выберите, запускать ли его на сайте (Newt) или на отдельном удаленном хосте. Подробности смотрите в документации.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Выберите стратегию", "internalResourceAuthDaemonStrategyLabel": "Местоположение", "internalResourceAuthDaemonSite": "На сайте", "internalResourceAuthDaemonSiteDescription": "На сайте работает демон Auth (Newt).", "internalResourceAuthDaemonRemote": "Удаленный хост", "internalResourceAuthDaemonRemoteDescription": "Демон Auth запускается на хост, который не является сайтом.", "internalResourceAuthDaemonPort": "Порт демона (опционально)", "orgAuthWhatsThis": "Где я могу найти ID моей организации?", "learnMore": "Узнать больше", "backToHome": "Вернуться домой", "needToSignInToOrg": "Нужно использовать провайдера идентификаций вашей организации?", "maintenanceMode": "Режим обслуживания", "maintenanceModeDescription": "Показать страницу обслуживания посетителям", "maintenanceModeType": "Тип режима обслуживания", "showMaintenancePage": "Показать страницу обслуживания посетителям", "enableMaintenanceMode": "Включить режим обслуживания", "automatic": "Автоматический", "automaticModeDescription": "Показывать страницу обслуживания только когда все цели бэкэнда недоступны или неисправны. Ваш ресурс продолжит работать нормально, пока хотя бы одна цель здорова.", "forced": "Принудительно", "forcedModeDescription": "Всегда показывать страницу обслуживания независимо от состояния бэкэнда. Используйте это для планового обслуживания, когда хотите предотвратить всех доступ.", "warning:": "Предупреждение:", "forcedeModeWarning": "Весь трафик будет направлен на страницу обслуживания. Ваши бекэнд ресурсы не будут получать никакие запросы.", "pageTitle": "Заголовок страницы", "pageTitleDescription": "Основной заголовок, отображаемый на странице обслуживания", "maintenancePageMessage": "Сообщение об обслуживании", "maintenancePageMessagePlaceholder": "Мы скоро вернемся! Наш сайт в настоящее время проходит плановое техническое обслуживание.", "maintenancePageMessageDescription": "Подробное сообщение, объясняющее обслуживание", "maintenancePageTimeTitle": "Предполагаемое время завершения (необязательно)", "maintenanceTime": "например, 2 часа, 1 ноября в 5:00 вечера", "maintenanceEstimatedTimeDescription": "Когда вы ожидаете завершения обслуживания", "editDomain": "Редактировать домен", "editDomainDescription": "Выберите домен для вашего ресурса", "maintenanceModeDisabledTooltip": "Для использования этой функции требуется действующая лицензия.", "maintenanceScreenTitle": "Сервис временно недоступен", "maintenanceScreenMessage": "В настоящее время мы испытываем технические трудности. Пожалуйста, зайдите позже.", "maintenanceScreenEstimatedCompletion": "Предполагаемое завершение:", "createInternalResourceDialogDestinationRequired": "Укажите адрес назначения. Это может быть имя хоста или IP-адрес.", "available": "Доступно", "archived": "Архивировано", "noArchivedDevices": "Архивные устройства не найдены", "deviceArchived": "Устройство архивировано", "deviceArchivedDescription": "Устройство успешно архивировано.", "errorArchivingDevice": "Ошибка архивирования устройства", "failedToArchiveDevice": "Не удалось архивировать устройство", "deviceQuestionArchive": "Вы уверены, что хотите архивировать это устройство?", "deviceMessageArchive": "Устройство будет архивировано и удалено из вашего списка активных устройств.", "deviceArchiveConfirm": "Архивировать устройство", "archiveDevice": "Архивировать устройство", "archive": "Архивировать", "deviceUnarchived": "Устройство разархивировано", "deviceUnarchivedDescription": "Устройство было успешно разархивировано.", "errorUnarchivingDevice": "Ошибка разархивирования устройства", "failedToUnarchiveDevice": "Не удалось распаковать устройство", "unarchive": "Разархивировать", "archiveClient": "Архивировать клиента", "archiveClientQuestion": "Вы уверены, что хотите архивировать этого клиента?", "archiveClientMessage": "Клиент будет архивирован и удален из вашего активного списка клиентов.", "archiveClientConfirm": "Архивировать клиента", "blockClient": "Блокировать клиента", "blockClientQuestion": "Вы уверены, что хотите заблокировать этого клиента?", "blockClientMessage": "Устройство будет вынуждено отключиться, если подключено в данный момент. Вы можете разблокировать устройство позже.", "blockClientConfirm": "Блокировать клиента", "active": "Активный", "usernameOrEmail": "Имя пользователя или Email", "selectYourOrganization": "Выберите вашу организацию", "signInTo": "Войти в", "signInWithPassword": "Продолжить с паролем", "noAuthMethodsAvailable": "Методы аутентификации для этой организации недоступны.", "enterPassword": "Введите ваш пароль", "enterMfaCode": "Введите код из вашего приложения-аутентификатора", "securityKeyRequired": "Пожалуйста, используйте ваш защитный ключ для входа.", "needToUseAnotherAccount": "Нужно использовать другой аккаунт?", "loginLegalDisclaimer": "Нажимая на кнопки ниже, вы подтверждаете, что прочитали, поняли и согласны с Условиями использования и Политикой конфиденциальности.", "termsOfService": "Условия предоставления услуг", "privacyPolicy": "Политика конфиденциальности", "userNotFoundWithUsername": "Пользователь с таким именем пользователя не найден.", "verify": "Подтвердить", "signIn": "Войти", "forgotPassword": "Забыли пароль?", "orgSignInTip": "Если вы вошли в систему ранее, вы можете ввести имя пользователя или адрес электронной почты, чтобы войти в систему с поставщиком идентификации вашей организации. Это проще!", "continueAnyway": "Все равно продолжить", "dontShowAgain": "Больше не показывать", "orgSignInNotice": "Знаете ли вы?", "signupOrgNotice": "Пытаетесь войти?", "signupOrgTip": "Вы пытаетесь войти через оператора идентификации вашей организации?", "signupOrgLink": "Войдите или зарегистрируйтесь через вашу организацию", "verifyEmailLogInWithDifferentAccount": "Использовать другую учетную запись", "logIn": "Войти", "deviceInformation": "Информация об устройстве", "deviceInformationDescription": "Информация о устройстве и агенте", "deviceSecurity": "Безопасность устройства", "deviceSecurityDescription": "Информация о позе безопасности устройства", "platform": "Платформа", "macosVersion": "Версия macOS", "windowsVersion": "Версия Windows", "iosVersion": "Версия iOS", "androidVersion": "Версия Android", "osVersion": "Версия ОС", "kernelVersion": "Версия ядра", "deviceModel": "Модель устройства", "serialNumber": "Серийный номер", "hostname": "Hostname", "firstSeen": "Первый раз виден", "lastSeen": "Последнее посещение", "biometricsEnabled": "Включены биометрические данные", "diskEncrypted": "Диск зашифрован", "firewallEnabled": "Брандмауэр включен", "autoUpdatesEnabled": "Автоматические обновления включены", "tpmAvailable": "Доступно TPM", "windowsAntivirusEnabled": "Антивирус включен", "macosSipEnabled": "Защита целостности системы (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Стилс-режим брандмауэра", "linuxAppArmorEnabled": "Броня", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "Просмотр информации и настроек устройства", "devicePendingApprovalDescription": "Это устройство ожидает одобрения", "deviceBlockedDescription": "Это устройство заблокировано. Оно не сможет подключаться к ресурсам, если не разблокировано.", "unblockClient": "Разблокировать клиента", "unblockClientDescription": "Устройство разблокировано", "unarchiveClient": "Разархивировать клиента", "unarchiveClientDescription": "Устройство было разархивировано", "block": "Блок", "unblock": "Разблокировать", "deviceActions": "Действия устройства", "deviceActionsDescription": "Управление статусом устройства и доступом", "devicePendingApprovalBannerDescription": "Это устройство ожидает одобрения. Он не сможет подключиться к ресурсам до утверждения.", "connected": "Подключено", "disconnected": "Отключено", "approvalsEmptyStateTitle": "Утверждения устройства не включены", "approvalsEmptyStateDescription": "Включите одобрение ролей для того, чтобы пользователи могли подключать новые устройства.", "approvalsEmptyStateStep1Title": "Перейти к ролям", "approvalsEmptyStateStep1Description": "Перейдите в настройки ролей вашей организации для настройки утверждений устройств.", "approvalsEmptyStateStep2Title": "Включить утверждения устройства", "approvalsEmptyStateStep2Description": "Редактировать роль и включить опцию 'Требовать утверждения устройств'. Пользователям с этой ролью потребуется подтверждение администратора для новых устройств.", "approvalsEmptyStatePreviewDescription": "Предпросмотр: Если включено, ожидающие запросы на устройство появятся здесь для проверки", "approvalsEmptyStateButtonText": "Управление ролями" } ================================================ FILE: messages/tr-TR.json ================================================ { "setupCreate": "Organizasyonu, siteyi ve kaynakları oluşturun", "headerAuthCompatibilityInfo": "Kimlik doğrulama belirteci eksik olduğunda 401 Yetkisiz yanıtı zorlamak için bunu etkinleştirin. Bu, sunucu sorunu olmadan kimlik bilgilerini göndermeyen tarayıcılar veya belirli HTTP kütüphaneleri için gereklidir.", "headerAuthCompatibility": "Genişletilmiş Uyumluluk", "setupNewOrg": "Yeni Organizasyon", "setupCreateOrg": "Organizasyon Oluştur", "setupCreateResources": "Kaynaklar Oluştur", "setupOrgName": "Organizasyon Adı", "orgDisplayName": "Bu organizasyonun görünen adıdır.", "orgId": "Organizasyon ID", "setupIdentifierMessage": "Bu organizasyonun benzersiz tanımlayıcısıdır.", "setupErrorIdentifier": "Organizasyon ID'si zaten alınmış. Lütfen başka bir tane seçin.", "componentsErrorNoMemberCreate": "Şu anda herhangi bir organizasyona üye değilsiniz. Başlamak için bir organizasyon oluşturun.", "componentsErrorNoMember": "Şu anda herhangi bir organizasyona üye değilsiniz.", "welcome": "Pangolin'e hoş geldiniz", "welcomeTo": "Hoş geldiniz", "componentsCreateOrg": "Bir Organizasyon Oluşturun", "componentsMember": "{count, plural, =0 {hiçbir organizasyon} one {bir organizasyon} other {# organizasyon}} üyesisiniz.", "componentsInvalidKey": "Geçersiz veya süresi dolmuş lisans anahtarları tespit edildi. Tüm özellikleri kullanmaya devam etmek için lisans koşullarına uyun.", "dismiss": "Kapat", "subscriptionViolationMessage": "Geçerli planınız için limitlerinizi aştınız. Planınız dahilinde kalmak için siteleri, kullanıcıları veya diğer kaynakları kaldırarak sorunu düzeltin.", "subscriptionViolationViewBilling": "Faturalamayı görüntüle", "componentsLicenseViolation": "Lisans İhlali: Bu sunucu, lisanslı sınırı olan {maxSites} sitesini aşarak {usedSites} site kullanmaktadır. Tüm özellikleri kullanmaya devam etmek için lisans koşullarına uyun.", "componentsSupporterMessage": "Pangolin'e {tier} olarak destek olduğunuz için teşekkür ederiz!", "inviteErrorNotValid": "Üzgünüz, ancak erişmeye çalıştığınız davet kabul edilmemiş veya artık geçerli değil gibi görünüyor.", "inviteErrorUser": "Üzgünüz, ancak erişmeye çalıştığınız davetin bu kullanıcı için olmadığı görünüyor.", "inviteLoginUser": "Lütfen doğru kullanıcı olarak oturum açtığınızdan emin olun.", "inviteErrorNoUser": "Üzgünüz, ancak erişmeye çalıştığınız davet, var olan bir kullanıcı için değil gibi görünüyor.", "inviteCreateUser": "Öncelikle bir hesap oluşturun.", "goHome": "Ana Sayfaya Dön", "inviteLogInOtherUser": "Başka bir kullanıcı olarak giriş yapın", "createAnAccount": "Bir Hesap Oluşturun", "inviteNotAccepted": "Davet Kabul Edilmedi", "authCreateAccount": "Başlamak için bir hesap oluşturun", "authNoAccount": "Hesabınız yok mu?", "email": "E-posta", "password": "Şifre", "confirmPassword": "Şifreyi Onayla", "createAccount": "Hesap Oluştur", "viewSettings": "Ayarları Görüntüle", "delete": "Sil", "name": "Ad", "online": "Çevrimiçi", "offline": "Çevrimdışı", "site": "Site", "dataIn": "Gelen Veri", "dataOut": "Giden Veri", "connectionType": "Bağlantı Türü", "tunnelType": "Tünel Türü", "local": "Yerel", "edit": "Düzenle", "siteConfirmDelete": "Site Silmeyi Onayla", "siteDelete": "Siteyi Sil", "siteMessageRemove": "Kaldırıldıktan sonra site artık erişilebilir olmayacaktır. Siteyle ilişkilendirilmiş tüm hedefler de kaldırılacaktır.", "siteQuestionRemove": "Siteyi organizasyondan kaldırmak istediğinizden emin misiniz?", "siteManageSites": "Siteleri Yönet", "siteDescription": "Özel ağlara erişimi etkinleştirmek için siteler oluşturun ve yönetin", "sitesBannerTitle": "Herhangi Bir Ağa Bağlan", "sitesBannerDescription": "Bir site, Pangolin'in kullanıcılara, halka açık veya özel kaynaklara, her yerden erişim sağlamak için uzak bir ağa bağlantı sunmasıdır. Site ağı bağlantısını (Newt) çalıştırabileceğiniz her yere kurarak bağlantıyı kurunuz.", "sitesBannerButtonText": "Site Kur", "approvalsBannerTitle": "Cihaz Erişimini Onayla veya Reddet", "approvalsBannerDescription": "Kullanıcılardan gelen cihaz erişim isteklerini gözden geçirin ve onaylayın veya reddedin. Cihaz onaylarının gerekli olduğu durumlarda, kullanıcıların cihazlarının kuruluşunuzun kaynaklarına bağlanabilmesi için yönetici onayı alması gerekecektir.", "approvalsBannerButtonText": "Daha fazla bilgi", "siteCreate": "Site Oluştur", "siteCreateDescription2": "Yeni bir site oluşturup bağlanmak için aşağıdaki adımları izleyin", "siteCreateDescription": "Kaynaklarınızı bağlamaya başlamak için yeni bir site oluşturun", "close": "Kapat", "siteErrorCreate": "Site oluşturulurken hata", "siteErrorCreateKeyPair": "Anahtar çifti veya site varsayılanları bulunamadı", "siteErrorCreateDefaults": "Site varsayılanları bulunamadı", "method": "Yöntem", "siteMethodDescription": "Bağlantıları nasıl açığa çıkaracağınız budur.", "siteLearnNewt": "Newt'i sisteminize nasıl kuracağınızı öğrenin", "siteSeeConfigOnce": "Konfigürasyonu yalnızca bir kez görebileceksiniz.", "siteLoadWGConfig": "WireGuard yapılandırması yükleniyor...", "siteDocker": "Docker Dağıtım Ayrıntılarını Genişlet", "toggle": "Geçiş", "dockerCompose": "Docker Compose", "dockerRun": "Docker Çalıştır", "siteLearnLocal": "Yerel siteler tünellemez, daha fazla bilgi edinin", "siteConfirmCopy": "Yapılandırmayı kopyaladım", "searchSitesProgress": "Siteleri ara...", "siteAdd": "Site Ekle", "siteInstallNewt": "Newt Yükle", "siteInstallNewtDescription": "Newt'i sisteminizde çalıştırma", "WgConfiguration": "WireGuard Yapılandırması", "WgConfigurationDescription": "Ağınıza bağlanmak için aşağıdaki yapılandırmayı kullanın", "operatingSystem": "İşletim Sistemi", "commands": "Komutlar", "recommended": "Önerilen", "siteNewtDescription": "En iyi kullanıcı deneyimi için Newt'i kullanın. WireGuard'ı arka planda kullanır ve özel kaynaklarınıza Pangolin kontrol panelinden LAN adresleriyle erişmenizi sağlar.", "siteRunsInDocker": "Docker'da Çalışır", "siteRunsInShell": "macOS, Linux, ve Windows'da kabukta çalışır", "siteErrorDelete": "Site silinirken hata", "siteErrorUpdate": "Site güncellenirken hata oluştu", "siteErrorUpdateDescription": "Site güncellenirken bir hata oluştu.", "siteUpdated": "Site güncellendi", "siteUpdatedDescription": "Site güncellendi.", "siteGeneralDescription": "Bu site için genel ayarları yapılandırın", "siteSettingDescription": "Sitenizdeki ayarları yapılandırın", "siteSetting": "{siteName} Ayarları", "siteNewtTunnel": "Newt Site (Önerilen)", "siteNewtTunnelDescription": "Ağınıza giriş noktası oluşturmanın en kolay yolu. Ekstra kurulum gerekmez.", "siteWg": "Temel WireGuard", "siteWgDescription": "Bir tünel oluşturmak için herhangi bir WireGuard istemcisi kullanın. Manuel NAT kurulumu gereklidir.", "siteWgDescriptionSaas": "Bir tünel oluşturmak için herhangi bir WireGuard istemcisi kullanın. Manuel NAT kurulumu gereklidir. YALNIZCA SELF HOSTED DÜĞÜMLERDE ÇALIŞIR", "siteLocalDescription": "Yalnızca yerel kaynaklar. Tünelleme yok.", "siteLocalDescriptionSaas": "Yerel kaynaklar yalnızca. Tünel oluşturma yok. Yalnızca uzak düğümlerde mevcuttur.", "siteSeeAll": "Tüm Siteleri Gör", "siteTunnelDescription": "Sitenize nasıl bağlanmak istediğinizi belirleyin", "siteNewtCredentials": "Kimlik Bilgileri", "siteNewtCredentialsDescription": "Bu, sitenin sunucu ile kimlik doğrulaması yapacağı yöntemdir", "remoteNodeCredentialsDescription": "Uzak düğümün sunucu ile kimliği nasıl doğrulayacağı budur", "siteCredentialsSave": "Kimlik Bilgilerinizi Kaydedin", "siteCredentialsSaveDescription": "Yalnızca bir kez görebileceksiniz. Güvenli bir yere kopyaladığınızdan emin olun.", "siteInfo": "Site Bilgilendirmesi", "status": "Durum", "shareTitle": "Paylaşım Bağlantılarını Yönet", "shareDescription": "Kaynaklarınıza geçici veya kalıcı erişim sağlamak için paylaşılabilir bağlantılar oluşturun", "shareSearch": "Paylaşım bağlantılarını ara...", "shareCreate": "Paylaşım Bağlantısı Oluştur", "shareErrorDelete": "Bağlantı silinirken hata oluştu", "shareErrorDeleteMessage": "Bağlantı silinirken bir hata oluştu", "shareDeleted": "Bağlantı silindi", "shareDeletedDescription": "Bağlantı silindi", "shareTokenDescription": "Erişim jetonunuz iki şekilde iletilebilir: sorgu parametresi olarak veya istek başlıklarında. Kimlik doğrulanmış erişim için her istekten müşteri tarafından iletilmelidir.", "accessToken": "Erişim Jetonu", "usageExamples": "Kullanım Örnekleri", "tokenId": "Jeton ID", "requestHeades": "İstek Başlıkları", "queryParameter": "Sorgu Parametresi", "importantNote": "Önemli Not", "shareImportantDescription": "Güvenlik nedenleriyle, mümkünse başlıklar üzerinden kullanılması sorgu parametrelerinden daha önerilir, çünkü sorgu parametreleri sunucu günlüklerinde veya tarayıcı geçmişinde kaydedilebilir.", "token": "Jeton", "shareTokenSecurety": "Erişim jetonunuzu güvende tutun. Herkese açık alanlarda veya istemci tarafı kodunda paylaşmayın.", "shareErrorFetchResource": "Kaynaklar getirilemedi", "shareErrorFetchResourceDescription": "Kaynaklar getirilirken bir hata oluştu", "shareErrorCreate": "Paylaşım bağlantısı oluşturma başarısız oldu", "shareErrorCreateDescription": "Paylaşım bağlantısı oluşturulurken bir hata oluştu", "shareCreateDescription": "Bu bağlantıya sahip olan herkes kaynağa erişebilir", "shareTitleOptional": "Başlık (isteğe bağlı)", "expireIn": "Süresi Dolacak", "neverExpire": "Hiçbir Zaman Sona Ermez", "shareExpireDescription": "Son kullanma süresi, bağlantının kullanılabilir ve kaynağa erişim sağlayacak süresidir. Bu süreden sonra bağlantı çalışmayı durduracak ve bu bağlantıyı kullanan kullanıcılar kaynağa erişimini kaybedecektir.", "shareSeeOnce": "Bu bağlantıyı yalnızca bir kez görebileceksiniz. Kopyaladığınızdan emin olun.", "shareAccessHint": "Bu bağlantıya sahip olan herkes kaynağa erişebilir. Dikkatle paylaşın.", "shareTokenUsage": "Erişim Jetonu Kullanımını Gör", "createLink": "Bağlantı Oluştur", "resourcesNotFound": "Hiçbir kaynak bulunamadı", "resourceSearch": "Kaynak ara", "openMenu": "Menüyü Aç", "resource": "Kaynak", "title": "Başlık", "created": "Oluşturulmuş", "expires": "Süresi Doluyor", "never": "Asla", "shareErrorSelectResource": "Lütfen bir kaynak seçin", "proxyResourceTitle": "Herkese Açık Kaynakları Yönet", "proxyResourceDescription": "Bir web tarayıcısı aracılığıyla kamuya açık kaynaklar oluşturun ve yönetin", "proxyResourcesBannerTitle": "Web Tabanlı Genel Erişim", "proxyResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla herkesin internette erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine, istemci tarafı yazılıma ihtiyaç duymazlar ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.", "clientResourceTitle": "Özel Kaynakları Yönet", "clientResourceDescription": "Sadece bağlı bir istemci aracılığıyla erişilebilen kaynakları oluşturun ve yönetin", "privateResourcesBannerTitle": "Sıfır Güven Özel Erişim", "privateResourcesBannerDescription": "Özel kaynaklar sıfır güven güvenliği kullanır, kullanıcılar ve makinelerin yalnızca açıkça izin verdiğiniz kaynaklara erişmesini sağlar. Bu kaynaklara güvenli bir sanal özel ağ üzerinden erişmek için kullanıcı cihazlarını veya makine müşterilerini bağlayın.", "resourcesSearch": "Kaynakları ara...", "resourceAdd": "Kaynak Ekle", "resourceErrorDelte": "Kaynak silinirken hata", "authentication": "Kimlik Doğrulama", "protected": "Korunan", "notProtected": "Korunmayan", "resourceMessageRemove": "Kaldırıldıktan sonra kaynak artık erişilebilir olmayacaktır. Kaynakla ilişkili tüm hedefler de kaldırılacaktır.", "resourceQuestionRemove": "Kaynağı organizasyondan kaldırmak istediğinizden emin misiniz?", "resourceHTTP": "HTTPS Kaynağı", "resourceHTTPDescription": "Tam nitelikli bir etki alanı adı kullanarak HTTPS üzerinden proxy isteklerini yönlendirin.", "resourceRaw": "Ham TCP/UDP Kaynağı", "resourceRawDescription": "Port numarası kullanarak ham TCP/UDP üzerinden proxy isteklerini yönlendirin.", "resourceRawDescriptionCloud": "Bir port numarası kullanarak ham TCP/UDP üzerinden istekleri proxy ile yönlendirin. UZAKTAN BİR DÜĞÜM KULLANIMINI GEREKTİRİR.", "resourceCreate": "Kaynak Oluştur", "resourceCreateDescription": "Yeni bir kaynak oluşturmak için aşağıdaki adımları izleyin", "resourceSeeAll": "Tüm Kaynakları Gör", "resourceInfo": "Kaynak Bilgilendirmesi", "resourceNameDescription": "Bu, kaynak için görünen addır.", "siteSelect": "Site seç", "siteSearch": "Site ara", "siteNotFound": "Herhangi bir site bulunamadı.", "selectCountry": "Ülke Seç", "searchCountries": "Ülkeleri ara...", "noCountryFound": "Ülke bulunamadı.", "siteSelectionDescription": "Bu site hedefe bağlantı sağlayacaktır.", "resourceType": "Kaynak Türü", "resourceTypeDescription": "Kaynağınıza nasıl erişmek istediğinizi belirleyin", "resourceHTTPSSettings": "HTTPS Ayarları", "resourceHTTPSSettingsDescription": "Kaynağınıza HTTPS üzerinden erişimin nasıl sağlanacağını yapılandırın", "domainType": "Alan Türü", "subdomain": "Alt Alan Adı", "baseDomain": "Temel Alan Adı", "subdomnainDescription": "Kaynağınızın erişilebileceği alt alan adı.", "resourceRawSettings": "TCP/UDP Ayarları", "resourceRawSettingsDescription": "Kaynaklara TCP/UDP üzerinden nasıl erişileceğini yapılandırın", "protocol": "Protokol", "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", "resourceAddEntrypoints": "Traefik: Başlangıç Noktaları Ekleyin", "resourceExposePorts": "Gerbil: Docker Compose'da Portları Açın", "resourceLearnRaw": "TCP/UDP kaynaklarını nasıl yapılandıracağınızı öğrenin", "resourceBack": "Kaynaklara Geri Dön", "resourceGoTo": "Kaynağa Git", "resourceDelete": "Kaynağı Sil", "resourceDeleteConfirm": "Kaynak Silmeyi Onayla", "visibility": "Görünürlük", "enabled": "Etkin", "disabled": "Devre Dışı", "general": "Genel", "generalSettings": "Genel Ayarlar", "proxy": "Vekil Sunucu", "internal": "Dahili", "rules": "Kurallar", "resourceSettingDescription": "Kaynağınızdaki ayarları yapılandırın", "resourceSetting": "{resourceName} Ayarları", "alwaysAllow": "Kimlik Doğrulamayı Atla", "alwaysDeny": "Erişimi Engelle", "passToAuth": "Kimlik Doğrulamasına Geç", "orgSettingsDescription": "Organizasyonunuzun genel ayarlarını yapılandırın", "orgGeneralSettings": "Organizasyon Ayarları", "orgGeneralSettingsDescription": "Organizasyon detaylarınızı ve yapılandırmanızı yönetin", "saveGeneralSettings": "Genel Ayarları Kaydet", "saveSettings": "Ayarları Kaydet", "orgDangerZone": "Tehlike Alanı", "orgDangerZoneDescription": "Bu organizasyonu sildikten sonra geri dönüş yoktur. Emin olun.", "orgDelete": "Organizasyonu Sil", "orgDeleteConfirm": "Organizasyon Silmeyi Onayla", "orgMessageRemove": "Bu işlem geri alınamaz ve tüm ilişkili verileri silecektir.", "orgMessageConfirm": "Onaylamak için lütfen aşağıya organizasyonun adını yazın.", "orgQuestionRemove": "Organizasyondan kaldırmak istediğinizden emin misiniz?", "orgUpdated": "Organizasyon güncellendi", "orgUpdatedDescription": "Organizasyon güncellendi.", "orgErrorUpdate": "Organizasyon güncellenemedi", "orgErrorUpdateMessage": "Organizasyon güncellenirken bir hata oluştu.", "orgErrorFetch": "Organizasyonlar getirilemedi", "orgErrorFetchMessage": "Organizasyonlarınız listelenirken bir hata oluştu", "orgErrorDelete": "Organizasyon silinemedi", "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", "accessUsersDescription": "Bu organizasyona erişimi olan kullanıcıları davet edin ve yönetin", "accessUsersSearch": "Kullanıcıları ara...", "accessUserCreate": "Kullanıcı Oluştur", "accessUserRemove": "Kullanıcıyı Kaldır", "username": "Kullanıcı Adı", "identityProvider": "General Information", "role": "Rol", "nameRequired": "Ad gereklidir", "accessRolesManage": "Rolleri Yönet", "accessRolesDescription": "Organizasyondaki kullanıcılar için rolleri oluşturun ve yönetin", "accessRolesSearch": "Rolleri ara...", "accessRolesAdd": "Rol Ekle", "accessRoleDelete": "Rolü Sil", "accessApprovalsManage": "Onayları Yönet", "accessApprovalsDescription": "Bu kuruluşa erişim için bekleyen onayları görüntüleyin ve yönetin", "description": "Açıklama", "inviteTitle": "Açık Davetiyeler", "inviteDescription": "Organizasyona katılmak için diğer kullanıcılar için davetleri yönetin", "inviteSearch": "Davetiyeleri ara...", "minutes": "Dakika", "hours": "Saat", "days": "Gün", "weeks": "Hafta", "months": "Ay", "years": "Yıl", "day": "{count, plural, one {# gün} other {# gün}}", "apiKeysTitle": "API Anahtar Bilgilendirmesi", "apiKeysConfirmCopy2": "API anahtarını kopyaladığınızı onaylamanız gerekmektedir.", "apiKeysErrorCreate": "API anahtarı oluşturulurken hata", "apiKeysErrorSetPermission": "İzinler ayarlanırken hata", "apiKeysCreate": "API Anahtarı Oluştur", "apiKeysCreateDescription": "Organizasyonunuz için yeni bir API anahtarı oluşturun", "apiKeysGeneralSettings": "İzinler", "apiKeysGeneralSettingsDescription": "Bu API anahtarının neler yapabileceğini belirleyin", "apiKeysList": "API Anahtarınız", "apiKeysSave": "API Anahtarınızı Kaydedin", "apiKeysSaveDescription": "Bunu yalnızca bir kez görebileceksiniz. Güvenli bir yere kopyaladığınızdan emin olun.", "apiKeysInfo": "API anahtarınız:", "apiKeysConfirmCopy": "API anahtarını kopyaladım", "generate": "Oluştur", "done": "Tamamlandı", "apiKeysSeeAll": "Tüm API Anahtarlarını Gör", "apiKeysPermissionsErrorLoadingActions": "API anahtarı eylemleri yüklenirken bir hata oluştu", "apiKeysPermissionsErrorUpdate": "İzin ayarları sırasında bir hata oluştu", "apiKeysPermissionsUpdated": "İzinler güncellendi", "apiKeysPermissionsUpdatedDescription": "İzinler güncellenmiştir.", "apiKeysPermissionsGeneralSettings": "İzinler", "apiKeysPermissionsGeneralSettingsDescription": "Bu API anahtarının neler yapabileceğini belirleyin", "apiKeysPermissionsSave": "İzinleri Kaydet", "apiKeysPermissionsTitle": "İzinler", "apiKeys": "API Anahtarları", "searchApiKeys": "API anahtarlarını ara...", "apiKeysAdd": "API Anahtarı Oluştur", "apiKeysErrorDelete": "API anahtarı silinirken bir hata oluştu", "apiKeysErrorDeleteMessage": "API anahtarı silinirken bir hata oluştu", "apiKeysQuestionRemove": "API anahtarını organizasyondan kaldırmak istediğinizden emin misiniz?", "apiKeysMessageRemove": "Kaldırıldığında, API anahtarı artık kullanılamayacaktır.", "apiKeysDeleteConfirm": "API Anahtarının Silinmesini Onaylayın", "apiKeysDelete": "API Anahtarını Sil", "apiKeysManage": "API Anahtarlarını Yönet", "apiKeysDescription": "API anahtarları entegrasyon API'sini doğrulamak için kullanılır", "apiKeysSettings": "{apiKeyName} Ayarları", "userTitle": "Tüm Kullanıcıları Yönet", "userDescription": "Sistemdeki tüm kullanıcıları görün ve yönetin", "userAbount": "Kullanıcı Yönetimi Hakkında", "userAbountDescription": "Bu tablo sistemdeki tüm kök kullanıcı nesnelerini gösterir. Her kullanıcı birden fazla organizasyona ait olabilir. Bir kullanıcıyı bir organizasyondan kaldırmak, onların kök kullanıcı nesnesini silmez - sistemde kalmaya devam ederler. Bir kullanıcıyı sistemden tamamen kaldırmak için, bu tablodaki silme işlemini kullanarak kök kullanıcı nesnesini silmelisiniz.", "userServer": "Sunucu Kullanıcıları", "userSearch": "Sunucu kullanıcılarını ara...", "userErrorDelete": "Kullanıcı silme hatası", "userDeleteConfirm": "Kullanıcı Silinmesini Onayla", "userDeleteServer": "Kullanıcıyı Sunucudan Sil", "userMessageRemove": "Kullanıcı tüm organizasyonlardan çıkarılacak ve tamamen sunucudan kaldırılacaktır.", "userQuestionRemove": "Kullanıcıyı sunucudan kalıcı olarak silmek istediğinizden emin misiniz?", "licenseKey": "Lisans Anahtarı", "valid": "Geçerli", "numberOfSites": "Site Sayısı", "licenseKeySearch": "Lisans anahtarlarını ara...", "licenseKeyAdd": "Lisans Anahtarı Ekle", "type": "Tür", "licenseKeyRequired": "Lisans anahtarı gereklidir", "licenseTermsAgree": "Lisans koşullarını kabul etmelisiniz", "licenseErrorKeyLoad": "Lisans anahtarları yüklenemedi", "licenseErrorKeyLoadDescription": "Lisans anahtarları yüklenirken bir hata oluştu.", "licenseErrorKeyDelete": "Lisans anahtarı silinemedi", "licenseErrorKeyDeleteDescription": "Lisans anahtarı silinirken bir hata oluştu.", "licenseKeyDeleted": "Lisans anahtarı silindi", "licenseKeyDeletedDescription": "Lisans anahtarı silinmiştir.", "licenseErrorKeyActivate": "Lisans anahtarı etkinleştirilemedi", "licenseErrorKeyActivateDescription": "Lisans anahtarı etkinleştirilirken bir hata oluştu.", "licenseAbout": "Lisans Hakkında", "communityEdition": "Topluluk Sürümü", "licenseAboutDescription": "Bu, Pangolin'i ticari bir ortamda kullanan işletme ve kurumsal kullanıcılar içindir. Pangolin'i kişisel kullanım için kullanıyorsanız, bu bölümü görmezden gelebilirsiniz.", "licenseKeyActivated": "Lisans anahtarı etkinleştirildi", "licenseKeyActivatedDescription": "Lisans anahtarı başarıyla etkinleştirildi.", "licenseErrorKeyRecheck": "Lisans anahtarları yeniden kontrol edilemedi", "licenseErrorKeyRecheckDescription": "Lisans anahtarları yeniden kontrol edilirken bir hata oluştu.", "licenseErrorKeyRechecked": "Lisans anahtarları yeniden kontrol edildi", "licenseErrorKeyRecheckedDescription": "Tüm lisans anahtarları yeniden kontrol edilmiştir", "licenseActivateKey": "Lisans Anahtarını Etkinleştir", "licenseActivateKeyDescription": "Etkinleştirmek için bir lisans anahtarı girin.", "licenseActivate": "Lisansı Etkinleştir", "licenseAgreement": "Bu kutuyu işaretleyerek, lisans anahtarınıza bağlı olan seviye ile ilgili lisans koşullarını okuduğunuzu ve kabul ettiğinizi onaylıyorsunuz.", "fossorialLicense": "Fossorial Ticari Lisans ve Abonelik Koşullarını Gör", "licenseMessageRemove": "Bu, lisans anahtarını ve onun tarafından verilen tüm izinleri kaldıracaktır.", "licenseMessageConfirm": "Onaylamak için lütfen aşağıya lisans anahtarını yazın.", "licenseQuestionRemove": "Lisans anahtarını silmek istediğinizden emin misiniz?", "licenseKeyDelete": "Lisans Anahtarını Sil", "licenseKeyDeleteConfirm": "Lisans Anahtarının Silinmesini Onaylayın", "licenseTitle": "Lisans Durumunu Yönet", "licenseTitleDescription": "Sistemdeki lisans anahtarlarını görüntüleyin ve yönetin", "licenseHost": "Ana Lisans", "licenseHostDescription": "Ana bilgisayar için ana lisans anahtarını yönetin.", "licensedNot": "Lisanssız", "hostId": "Ana Bilgisayar Kimliği", "licenseReckeckAll": "Tüm Anahtarları Yeniden Kontrol Et", "licenseSiteUsage": "Site Kullanımı", "licenseSiteUsageDecsription": "Bu lisansı kullanan sitelerin sayısını görüntüleyin.", "licenseNoSiteLimit": "Lisanssız ana bilgisayar kullanan site sayısında herhangi bir sınır yoktur.", "licensePurchase": "Lisans Satın Al", "licensePurchaseSites": "Ek Siteler Satın Al", "licenseSitesUsedMax": "{usedSites} / {maxSites} siteleri kullanıldı", "licenseSitesUsed": "{count, plural, =0 {# site} one {# site} other {# site}} sistemde bulunmaktadır.", "licensePurchaseDescription": "{selectedMode, select, license {Lisans satın almak için kaç site istediğinizi seçin. Daha sonra daha fazla site ekleyebilirsiniz.} other {mevcut lisansınıza kaç site ekleneceğini seçin.}}", "licenseFee": "Lisans ücreti", "licensePriceSite": "Site başına fiyat", "total": "Toplam", "licenseContinuePayment": "Ödemeye Devam Et", "pricingPage": "fiyatlandırma sayfası", "pricingPortal": "Satın Alma Portalını Gör", "licensePricingPage": "En güncel fiyatlandırma ve indirimler için lütfen ", "invite": "Davetiye", "inviteRegenerate": "Daveti Tekrar Üret", "inviteRegenerateDescription": "Önceki daveti iptal et ve yenisini oluştur", "inviteRemove": "Daveti Kaldır", "inviteRemoveError": "Kaldırma işlemi başarısız oldu", "inviteRemoveErrorDescription": "Daveti kaldırırken bir hata oluştu.", "inviteRemoved": "Davetiye kaldırıldı", "inviteRemovedDescription": "{email} için olan davetiye kaldırıldı.", "inviteQuestionRemove": "Davetiyeyi kaldırmak istediğinizden emin misiniz?", "inviteMessageRemove": "Kaldırıldıktan sonra bu davetiye artık geçerli olmayacak. Kullanıcı tekrar davet edilebilir.", "inviteMessageConfirm": "Onaylamak için lütfen aşağıya davetiyenin e-posta adresini yazın.", "inviteQuestionRegenerate": "Are you sure you want to regenerate the invitation for{email, plural, ='' {}, other { for #}}? This will revoke the previous invitation.", "inviteRemoveConfirm": "Daveti Kaldırmayı Onayla", "inviteRegenerated": "Davetiye Yenilendi", "inviteSent": "{email} adresine yeni bir davet gönderildi.", "inviteSentEmail": "Kullanıcıya e-posta bildirimi gönder", "inviteGenerate": "{email} için yeni bir davetiye oluşturuldu.", "inviteDuplicateError": "Yinelenen Davet", "inviteDuplicateErrorDescription": "Bu kullanıcı için zaten bir davetiye mevcut.", "inviteRateLimitError": "Hız Sınırı Aşıldı", "inviteRateLimitErrorDescription": "Saatte 3 yenileme sınırını aştınız. Lütfen daha sonra tekrar deneyiniz.", "inviteRegenerateError": "Daveti Tekrar Üretme Başarısız", "inviteRegenerateErrorDescription": "Daveti yenilerken bir hata oluştu.", "inviteValidityPeriod": "Geçerlilik Süresi", "inviteValidityPeriodSelect": "Geçerlilik süresini seçin", "inviteRegenerateMessage": "Davetiye yenilendi. Kullanıcının daveti kabul etmek için aşağıdaki bağlantıya erişmesi gerekiyor.", "inviteRegenerateButton": "Yeniden Üret", "expiresAt": "Bitiş Tarihi", "accessRoleUnknown": "Bilinmeyen Rol", "placeholder": "Yer Tutucu", "userErrorOrgRemove": "Kullanıcı kaldırma başarısız oldu", "userErrorOrgRemoveDescription": "Kullanıcı kaldırılırken bir hata oluştu.", "userOrgRemoved": "Kullanıcı kaldırıldı", "userOrgRemovedDescription": "{email} kullanıcı organizasyondan kaldırılmıştır.", "userQuestionOrgRemove": "Kullanıcıyı organizasyondan kaldırmak istediğinizden emin misiniz?", "userMessageOrgRemove": "Kaldırıldığında, bu kullanıcı organizasyona artık erişim sağlayamayacak. Kullanıcı tekrar davet edilebilir, ancak daveti kabul etmesi gerekecek.", "userRemoveOrgConfirm": "Kullanıcıyı Kaldırmayı Onayla", "userRemoveOrg": "Kullanıcıyı Organizasyondan Kaldır", "users": "Kullanıcılar", "accessRoleMember": "Üye", "accessRoleOwner": "Sahip", "userConfirmed": "Onaylandı", "idpNameInternal": "Dahili", "emailInvalid": "Geçersiz e-posta adresi", "inviteValidityDuration": "Lütfen bir süre seçin", "accessRoleSelectPlease": "Lütfen bir rol seçin", "usernameRequired": "Kullanıcı adı gereklidir", "idpSelectPlease": "Lütfen bir kimlik sağlayıcı seçin", "idpGenericOidc": "Genel OAuth2/OIDC sağlayıcısı.", "accessRoleErrorFetch": "Roller alınamadı", "accessRoleErrorFetchDescription": "Roller alınırken bir hata oluştu", "idpErrorFetch": "Kimlik sağlayıcıları alınamadı", "idpErrorFetchDescription": "Kimlik sağlayıcıları alınırken bir hata oluştu", "userErrorExists": "Kullanıcı Zaten Mevcut", "userErrorExistsDescription": "Bu kullanıcı zaten organizasyonun bir üyesidir.", "inviteError": "Kullanıcı davet etme başarısız oldu", "inviteErrorDescription": "Kullanıcı davet edilirken bir hata oluştu", "userInvited": "Kullanıcı davet edildi", "userInvitedDescription": "Kullanıcı başarıyla davet edilmiştir.", "userErrorCreate": "Kullanıcı oluşturulamadı", "userErrorCreateDescription": "Kullanıcı oluşturulurken bir hata oluştu", "userCreated": "Kullanıcı oluşturuldu", "userCreatedDescription": "Kullanıcı başarıyla oluşturulmuştur.", "userTypeInternal": "Dahili Kullanıcı", "userTypeInternalDescription": "Kullanıcıyı doğrudan organizasyona davet edin.", "userTypeExternal": "Harici Kullanıcı", "userTypeExternalDescription": "Harici bir kimlik sağlayıcısıyla kullanıcı oluşturun.", "accessUserCreateDescription": "Yeni bir kullanıcı oluşturmak için aşağıdaki adımları izleyin", "userSeeAll": "Tüm Kullanıcıları Gör", "userTypeTitle": "Kullanıcı Türü", "userTypeDescription": "Kullanıcı oluşturma yöntemini belirleyin", "userSettings": "Kullanıcı Bilgileri", "userSettingsDescription": "Yeni kullanıcı için detayları girin", "inviteEmailSent": "Kullanıcıya davet e-postası gönder", "inviteValid": "Geçerli Süresi", "selectDuration": "Süreyi seçin", "selectResource": "Kaynak Seçin", "filterByResource": "Kaynağa Göre Filtrele", "selectApprovalState": "Onay Durumunu Seçin", "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", "deniedApproval": "Reddedilen Onay", "all": "Tümü", "deny": "Reddet", "viewDetails": "Ayrıntıları Gör", "requestingNewDeviceApproval": "yeni bir cihaz talep etti", "resetFilters": "Filtreleri Sıfırla", "totalBlocked": "Pangolin Tarafından Engellenen İstekler", "totalRequests": "Toplam İstekler", "requestsByCountry": "Ülkeye Göre İstekler", "requestsByDay": "Güne Göre İstekler", "blocked": "Engellendi", "allowed": "İzin Verildi", "topCountries": "En İyi Ülkeler", "accessRoleSelect": "Rol seçin", "inviteEmailSentDescription": "Kullanıcıya erişim bağlantısı ile bir e-posta gönderildi. Daveti kabul etmek için bağlantıya erişmelidirler.", "inviteSentDescription": "Kullanıcı davet edilmiştir. Daveti kabul etmek için aşağıdaki bağlantıya erişmelidirler.", "inviteExpiresIn": "Davetiye {days, plural, one {# gün} other {# gün}} içinde sona erecektir.", "idpTitle": "General Information", "idpSelect": "Dış kullanıcı için kimlik sağlayıcıyı seçin", "idpNotConfigured": "Herhangi bir kimlik sağlayıcı yapılandırılmamış. Harici kullanıcılar oluşturulmadan önce lütfen bir kimlik sağlayıcı yapılandırın.", "usernameUniq": "Bu, seçilen kimlik sağlayıcısında bulunan benzersiz kullanıcı adıyla eşleşmelidir.", "emailOptional": "E-posta (İsteğe Bağlı)", "nameOptional": "İsim (İsteğe Bağlı)", "accessControls": "Erişim Kontrolleri", "userDescription2": "Bu kullanıcı üzerindeki ayarları yönetin", "accessRoleErrorAdd": "Kullanıcıyı role ekleme başarısız oldu", "accessRoleErrorAddDescription": "Kullanıcı role eklenirken bir hata oluştu.", "userSaved": "Kullanıcı kaydedildi", "userSavedDescription": "Kullanıcı güncellenmiştir.", "autoProvisioned": "Otomatik Sağlandı", "autoProvisionedDescription": "Bu kullanıcının kimlik sağlayıcısı tarafından otomatik olarak yönetilmesine izin ver", "accessControlsDescription": "Bu kullanıcının organizasyonda neleri erişebileceğini ve yapabileceğini yönetin", "accessControlsSubmit": "Erişim Kontrollerini Kaydet", "roles": "Roller", "accessUsersRoles": "Kullanıcılar ve Roller Yönetin", "accessUsersRolesDescription": "Kullanıcılara davet gönderin ve organizasyona erişimi yönetmek için rollere ekleyin", "key": "Anahtar", "createdAt": "Oluşturulma Tarihi", "proxyErrorInvalidHeader": "Geçersiz özel Ana Bilgisayar Başlığı değeri. Alan adı formatını kullanın veya özel Ana Bilgisayar Başlığını ayarlamak için boş bırakın.", "proxyErrorTls": "Geçersiz TLS Sunucu Adı. Alan adı formatını kullanın veya TLS Sunucu Adını kaldırmak için boş bırakılsın.", "proxyEnableSSL": "SSL Etkinleştir", "proxyEnableSSLDescription": "Hedeflere güvenli HTTPS bağlantıları için SSL/TLS şifrelemesini etkinleştirin.", "target": "Hedef", "configureTarget": "Hedefleri Yapılandır", "targetErrorFetch": "Hedefleri alamadı", "targetErrorFetchDescription": "Hedefler alınırken bir hata oluştu", "siteErrorFetch": "kaynağa ulaşılamadı", "siteErrorFetchDescription": "kaynağa ulaşılırken bir hata oluştu", "targetErrorDuplicate": "Yinelenen hedef", "targetErrorDuplicateDescription": "Bu ayarlarla zaten bir hedef mevcut", "targetWireGuardErrorInvalidIp": "Geçersiz hedef IP'si", "targetWireGuardErrorInvalidIpDescription": "Hedef IP, site alt ağında olmalıdır", "targetsUpdated": "Hedefler Güncellendi", "targetsUpdatedDescription": "Hedefler ve ayarlar başarıyla güncellendi", "targetsErrorUpdate": "Hedefler güncellenemedi", "targetsErrorUpdateDescription": "Hedefler güncellenirken bir hata oluştu", "targetTlsUpdate": "TLS ayarları güncellendi", "targetTlsUpdateDescription": "TLS ayarları başarıyla güncellendi", "targetErrorTlsUpdate": "TLS ayarları güncellenemedi", "targetErrorTlsUpdateDescription": "TLS ayarlarını güncellerken bir hata oluştu", "proxyUpdated": "Proxy ayarları güncellendi", "proxyUpdatedDescription": "Proxy ayarları başarıyla güncellendi", "proxyErrorUpdate": "Proxy ayarları güncellenemedi", "proxyErrorUpdateDescription": "Proxy ayarlarını güncellerken bir hata oluştu", "targetAddr": "Host", "targetPort": "Bağlantı Noktası", "targetProtocol": "Protokol", "targetTlsSettings": "HTTPS & TLS Settings", "targetTlsSettingsDescription": "SSL/TLS ayarlarını kaynak için yapılandırın", "targetTlsSettingsAdvanced": "Gelişmiş TLS Ayarları", "targetTlsSni": "TLS Sunucu Adı", "targetTlsSniDescription": "SNI için kullanılacak TLS Sunucu Adı'", "targetTlsSubmit": "Ayarları Kaydet", "targets": "Hedefler Konfigürasyonu", "targetsDescription": "Trafiği arka uç hizmetlerine yönlendirmek için hedefleri ayarlayın", "targetStickySessions": "Yapışkan Oturumları Etkinleştir", "targetStickySessionsDescription": "Bağlantıları oturum süresince aynı arka uç hedef üzerinde tutun.", "methodSelect": "Yöntemi Seç", "targetSubmit": "Hedef Ekle", "targetNoOne": "Bu kaynağın hedefleri yok. Arka uca gönderilecek istekleri yapılandırmak için bir hedef ekleyin.", "targetNoOneDescription": "Yukarıdaki birden fazla hedef ekleyerek yük dengeleme etkinleştirilecektir.", "targetsSubmit": "Hedefleri Kaydet", "addTarget": "Hedef Ekle", "targetErrorInvalidIp": "Geçersiz IP adresi", "targetErrorInvalidIpDescription": "Lütfen geçerli bir IP adresi veya host adı girin", "targetErrorInvalidPort": "Geçersiz port", "targetErrorInvalidPortDescription": "Lütfen geçerli bir port numarası girin", "targetErrorNoSite": "Hiçbir site seçili değil", "targetErrorNoSiteDescription": "Lütfen hedef için bir site seçin", "targetCreated": "Hedef oluşturuldu", "targetCreatedDescription": "Hedef başarıyla oluşturuldu", "targetErrorCreate": "Hedef oluşturma başarısız oldu", "targetErrorCreateDescription": "Hedef oluşturulurken bir hata oluştu", "tlsServerName": "TLS Sunucu Adı", "tlsServerNameDescription": "SNI için kullanılacak TLS sunucu adı", "save": "Kaydet", "proxyAdditional": "Ek Proxy Ayarları", "proxyAdditionalDescription": "Kaynağın proxy ayarlarını nasıl yöneteceği yapılandırın", "proxyCustomHeader": "Özel Ana Bilgisayar Başlığı", "proxyCustomHeaderDescription": "İstekleri proxy'lerken ayarlanacak ana bilgisayar başlığı. Varsayılanı kullanmak için boş bırakılır.", "proxyAdditionalSubmit": "Proxy Ayarlarını Kaydet", "subnetMaskErrorInvalid": "Geçersiz alt ağ maskesi. 0 ile 32 arasında olmalıdır.", "ipAddressErrorInvalidFormat": "Geçersiz IP adresi formatı", "ipAddressErrorInvalidOctet": "Geçersiz IP adresi okteti", "path": "Yol", "matchPath": "Yol Eşleştir", "ipAddressRange": "IP Aralığı", "rulesErrorFetch": "Kurallar alınamadı", "rulesErrorFetchDescription": "Kurallar alınırken bir hata oluştu", "rulesErrorDuplicate": "Yinelenen kural", "rulesErrorDuplicateDescription": "Bu ayarlara sahip bir kural zaten mevcut", "rulesErrorInvalidIpAddressRange": "Geçersiz CIDR", "rulesErrorInvalidIpAddressRangeDescription": "Lütfen geçerli bir CIDR değeri girin", "rulesErrorInvalidUrl": "Geçersiz URL yolu", "rulesErrorInvalidUrlDescription": "Lütfen geçerli bir URL yolu değeri girin", "rulesErrorInvalidIpAddress": "Geçersiz IP", "rulesErrorInvalidIpAddressDescription": "Lütfen geçerli bir IP adresi girin", "rulesErrorUpdate": "Kurallar güncellenemedi", "rulesErrorUpdateDescription": "Kurallar güncellenirken bir hata oluştu", "rulesUpdated": "Kuralları Etkinleştir", "rulesUpdatedDescription": "Kural değerlendirmesi güncellendi", "rulesMatchIpAddressRangeDescription": "CIDR formatında bir adres girin (örneğin, 103.21.244.0/22)", "rulesMatchIpAddress": "Bir IP adresi girin (örneğin, 103.21.244.12)", "rulesMatchUrl": "Bir URL yolu veya deseni girin (örneğin, /api/v1/todos veya /api/v1/*)", "rulesErrorInvalidPriority": "Geçersiz Öncelik", "rulesErrorInvalidPriorityDescription": "Lütfen geçerli bir öncelik girin", "rulesErrorDuplicatePriority": "Yinelenen Öncelikler", "rulesErrorDuplicatePriorityDescription": "Lütfen benzersiz öncelikler girin", "ruleUpdated": "Kurallar güncellendi", "ruleUpdatedDescription": "Kurallar başarıyla güncellendi", "ruleErrorUpdate": "Operasyon başarısız oldu", "ruleErrorUpdateDescription": "Kaydetme operasyonu sırasında bir hata oluştu", "rulesPriority": "Öncelik", "rulesAction": "Aksiyon", "rulesMatchType": "Eşleşme Türü", "value": "Değer", "rulesAbout": "Kurallar Hakkında", "rulesAboutDescription": "Kurallar, kaynağa erişimi belirli kriterlere göre kontrol etmenizi sağlar. IP adresi veya URL yolu temelinde erişimi izin vermek veya engellemek için kurallar oluşturabilirsiniz.", "rulesActions": "Aksiyonlar", "rulesActionAlwaysAllow": "Her Zaman İzin Ver: Tüm kimlik doğrulama yöntemlerini atlayın", "rulesActionAlwaysDeny": "Her Zaman Reddedin: Tüm istekleri engelleyin; kimlik doğrulaması yapılamaz", "rulesActionPassToAuth": "Kimlik Doğrulamasına Geç: Kimlik doğrulama yöntemlerinin denenmesine izin ver", "rulesMatchCriteria": "Eşleşme Kriterleri", "rulesMatchCriteriaIpAddress": "Belirli bir IP adresi ile eşleşme", "rulesMatchCriteriaIpAddressRange": "CIDR gösteriminde bir IP adresi aralığı ile eşleşme", "rulesMatchCriteriaUrl": "Bir URL yolu veya deseni ile eşleşme", "rulesEnable": "Kuralları Etkinleştir", "rulesEnableDescription": "Bu kaynak için kural değerlendirmesini etkinleştirin veya devre dışı bırakın", "rulesResource": "Kaynak Kuralları Yapılandırması", "rulesResourceDescription": "Kaynağa erişimi kontrol etmek için kuralları yapılandırın", "ruleSubmit": "Kural Ekle", "rulesNoOne": "Kural yok. Formu kullanarak bir kural ekleyin.", "rulesOrder": "Kurallar, artan öncelik sırasına göre değerlendirilir.", "rulesSubmit": "Kuralları Kaydet", "resourceErrorCreate": "Kaynak oluşturma hatası", "resourceErrorCreateDescription": "Kaynak oluşturulurken bir hata oluştu", "resourceErrorCreateMessage": "Kaynak oluşturma hatası:", "resourceErrorCreateMessageDescription": "Beklenmeyen bir hata oluştu", "sitesErrorFetch": "Siteler alınırken hata oluştu", "sitesErrorFetchDescription": "Siteler alınırken bir hata oluştu", "domainsErrorFetch": "Alanlar alınırken hata oluştu", "domainsErrorFetchDescription": "Alanlar alınırken bir hata oluştu", "none": "Hiçbiri", "unknown": "Bilinmiyor", "resources": "Kaynaklar", "resourcesDescription": "Kaynaklar, özel ağda çalışan uygulamalara proxy olarak hizmet eder. Özel ağınızdaki herhangi bir HTTP/HTTPS veya ham TCP/UDP hizmeti için bir kaynak oluşturun. Her kaynak, şifreli bir WireGuard tüneli aracılığıyla özel, güvenli bağlanabilirliği etkinleştirmek için bir siteye bağlı olmalıdır.", "resourcesWireGuardConnect": "WireGuard şifreleme ile güvenli bağlantı", "resourcesMultipleAuthenticationMethods": "Birden fazla kimlik doğrulama yöntemi yapılandırın", "resourcesUsersRolesAccess": "Kullanıcı ve rol tabanlı erişim kontrolü", "resourcesErrorUpdate": "Kaynak değiştirilemedi", "resourcesErrorUpdateDescription": "Kaynak güncellenirken bir hata oluştu", "access": "Erişim", "accessControl": "Erişim Kontrolü", "shareLink": "{resource} Paylaşım Bağlantısı", "resourceSelect": "Kaynak seçin", "shareLinks": "Paylaşım Bağlantıları", "share": "Paylaşılabilir Bağlantılar", "shareDescription2": "Kaynaklarınıza geçici veya sınırsız erişim sağlamak için paylaşılabilir bağlantılar oluşturun. Bağlantı oluştururken sona erme süresini yapılandırabilirsiniz.", "shareEasyCreate": "Kolayca oluştur ve paylaş", "shareConfigurableExpirationDuration": "Yapılandırılabilir sona erme süresi", "shareSecureAndRevocable": "Güvenli ve iptal edilebilir", "nameMin": "İsim en az {len} karakter olmalıdır.", "nameMax": "İsim {len} karakterden uzun olmamalıdır.", "sitesConfirmCopy": "Yapılandırmayı kopyaladığınızı onaylayın.", "unknownCommand": "Bilinmeyen komut", "newtErrorFetchReleases": "Sürüm bilgileri alınamadı: {err}", "newtErrorFetchLatest": "Son sürüm alınırken hata: {err}", "newtEndpoint": "Uç Nokta", "newtId": "Kimlik", "newtSecretKey": "Gizli", "architecture": "Mimari", "sites": "Siteler", "siteWgAnyClients": "Herhangi bir WireGuard istemcisi kullanarak bağlanın. Dahili kaynaklara eş IP adresini kullanarak erişmeniz gerekecek.", "siteWgCompatibleAllClients": "Tüm WireGuard istemcileriyle uyumlu", "siteWgManualConfigurationRequired": "Manuel yapılandırma gerekli", "userErrorNotAdminOrOwner": "Kullanıcı yönetici veya sahibi değil", "pangolinSettings": "Ayarlar - Pangolin", "accessRoleYour": "Rolünüz:", "accessRoleSelect2": "Rolleri seçin", "accessUserSelect": "Kullanıcıları seçin", "otpEmailEnter": "Bir e-posta girin", "otpEmailEnterDescription": "E-posta girdikten sonra girdi alanına yazıp enter'a basın.", "otpEmailErrorInvalid": "Geçersiz e-posta adresi. Joker karakter (*) yerel kısmın tamamı olmalıdır.", "otpEmailSmtpRequired": "SMTP Gerekli", "otpEmailSmtpRequiredDescription": "Tek seferlik şifre kimlik doğrulamasını kullanmak için, sunucuda SMTP etkinleştirilmelidir.", "otpEmailTitle": "Tek Seferlik Şifreler", "otpEmailTitleDescription": "Kaynak erişimi için e-posta tabanlı kimlik doğrulamasını zorunlu kılın", "otpEmailWhitelist": "E-posta Beyaz Listesi", "otpEmailWhitelistList": "Beyaz Listeye Alınan E-postalar", "otpEmailWhitelistListDescription": "Yalnızca bu e-posta adresleriyle kullanıcılar bu kaynağa erişebilecektir. E-postalarına gönderilen tek seferlik şifreyi girmeleri istenecektir. Bir etki alanından herhangi bir e-posta adresine izin vermek için joker karakterler (*@example.com) kullanılabilir.", "otpEmailWhitelistSave": "Beyaz Listeyi Kaydet", "passwordAdd": "Şifre Ekle", "passwordRemove": "Şifre Kaldır", "pincodeAdd": "PIN Kodu Ekle", "pincodeRemove": "PIN Kodu Kaldır", "resourceAuthMethods": "Kimlik Doğrulama Yöntemleri", "resourceAuthMethodsDescriptions": "Ek kimlik doğrulama yöntemleriyle kaynağa erişime izin verin", "resourceAuthSettingsSave": "Başarıyla kaydedildi", "resourceAuthSettingsSaveDescription": "Kimlik doğrulama ayarları kaydedildi", "resourceErrorAuthFetch": "Veriler alınamadı", "resourceErrorAuthFetchDescription": "Veri alınırken bir hata oluştu", "resourceErrorPasswordRemove": "Kaynak şifresi kaldırılırken hata oluştu", "resourceErrorPasswordRemoveDescription": "Kaynak şifresi kaldırılırken bir hata oluştu", "resourceErrorPasswordSetup": "Kaynak şifresi ayarlanırken hata oluştu", "resourceErrorPasswordSetupDescription": "Kaynak şifresi ayarlanırken bir hata oluştu", "resourceErrorPincodeRemove": "Kaynak pincode kaldırılırken hata oluştu", "resourceErrorPincodeRemoveDescription": "Kaynak pincode kaldırılırken bir hata oluştu", "resourceErrorPincodeSetup": "Kaynak PIN kodu ayarlanırken hata oluştu", "resourceErrorPincodeSetupDescription": "Kaynak PIN kodu ayarlanırken bir hata oluştu", "resourceErrorUsersRolesSave": "Roller kaydedilemedi", "resourceErrorUsersRolesSaveDescription": "Roller ayarlanırken bir hata oluştu", "resourceErrorWhitelistSave": "Beyaz liste kaydedilemedi", "resourceErrorWhitelistSaveDescription": "Beyaz liste kaydedilirken bir hata oluştu", "resourcePasswordSubmit": "Parola Korumasını Etkinleştir", "resourcePasswordProtection": "Parola Koruması {status}", "resourcePasswordRemove": "Kaynak parolası kaldırıldı", "resourcePasswordRemoveDescription": "Kaynak parolası başarıyla kaldırıldı", "resourcePasswordSetup": "Kaynak parolası ayarlandı", "resourcePasswordSetupDescription": "Kaynak parolası başarıyla ayarlandı", "resourcePasswordSetupTitle": "Parola Ayarla", "resourcePasswordSetupTitleDescription": "Bu kaynağı korumak için bir parola ayarlayın", "resourcePincode": "PIN Kodu", "resourcePincodeSubmit": "PIN Kodu Korumasını Etkinleştir", "resourcePincodeProtection": "PIN Kodu Koruması {status}", "resourcePincodeRemove": "Kaynak pincode kaldırıldı", "resourcePincodeRemoveDescription": "Kaynak parolası başarıyla kaldırıldı", "resourcePincodeSetup": "Kaynak PIN kodu ayarlandı", "resourcePincodeSetupDescription": "Kaynak pincode başarıyla ayarlandı", "resourcePincodeSetupTitle": "Pincode Ayarla", "resourcePincodeSetupTitleDescription": "Bu kaynağı korumak için bir pincode ayarlayın", "resourceRoleDescription": "Yöneticiler her zaman bu kaynağa erişebilir.", "resourceUsersRoles": "Erişim Kontrolleri", "resourceUsersRolesDescription": "Bu kaynağı kimlerin ziyaret edebileceği kullanıcıları ve rolleri yapılandırın", "resourceUsersRolesSubmit": "Erişim Kontrollerini Kaydet", "resourceWhitelistSave": "Başarıyla kaydedildi", "resourceWhitelistSaveDescription": "Beyaz liste ayarları kaydedildi", "ssoUse": "Platform SSO'sunu Kullanın", "ssoUseDescription": "Mevcut kullanıcılar yalnızca bir kez giriş yapmak zorunda kalacaktır bu etkinleştirildiğinde bütün kaynaklar için.", "proxyErrorInvalidPort": "Geçersiz port numarası", "subdomainErrorInvalid": "Geçersiz alt domain", "domainErrorFetch": "Alanlar alınırken hata oluştu", "domainErrorFetchDescription": "Alanlar alınırken bir hata oluştu", "resourceErrorUpdate": "Kaynak güncellenemedi", "resourceErrorUpdateDescription": "Kaynak güncellenirken bir hata oluştu", "resourceUpdated": "Kaynak güncellendi", "resourceUpdatedDescription": "Kaynak başarıyla güncellendi", "resourceErrorTransfer": "Kaynak aktarılamadı", "resourceErrorTransferDescription": "Kaynak aktarılırken bir hata oluştu", "resourceTransferred": "Kaynak aktarıldı", "resourceTransferredDescription": "Kaynak başarıyla aktarıldı", "resourceErrorToggle": "Kaynak değiştirilemedi", "resourceErrorToggleDescription": "Kaynak güncellenirken bir hata oluştu", "resourceVisibilityTitle": "Görünürlük", "resourceVisibilityTitleDescription": "Kaynak görünürlüğünü tamamen etkinleştirin veya devre dışı bırakın", "resourceGeneral": "Genel Ayarlar", "resourceGeneralDescription": "Bu kaynak için genel ayarları yapılandırın", "resourceEnable": "Kaynağı Etkinleştir", "resourceTransfer": "Kaynağı Aktar", "resourceTransferDescription": "Bu kaynağı farklı bir siteye aktarın", "resourceTransferSubmit": "Kaynağı Aktar", "siteDestination": "Hedef Site", "searchSites": "Siteleri ara", "countries": "Ülkeler", "accessRoleCreate": "Rol Oluştur", "accessRoleCreateDescription": "Kullanıcıları gruplamak ve izinlerini yönetmek için yeni bir rol oluşturun.", "accessRoleEdit": "Rol Düzenle", "accessRoleEditDescription": "Rol bilgilerini düzenleyin.", "accessRoleCreateSubmit": "Rol Oluştur", "accessRoleCreated": "Rol oluşturuldu", "accessRoleCreatedDescription": "Rol başarıyla oluşturuldu.", "accessRoleErrorCreate": "Rol oluşturulamadı", "accessRoleErrorCreateDescription": "Rol oluşturulurken bir hata oluştu.", "accessRoleUpdateSubmit": "Rolü Güncelle", "accessRoleUpdated": "Rol güncellendi", "accessRoleUpdatedDescription": "Rol başarıyla güncellendi.", "accessApprovalUpdated": "Onay işlendi", "accessApprovalApprovedDescription": "Onay İsteği kararını onaylandı olarak ayarlayın.", "accessApprovalDeniedDescription": "Onay İsteği kararını reddedildi olarak ayarlayın.", "accessRoleErrorUpdate": "Rol güncellenemedi", "accessRoleErrorUpdateDescription": "Rol güncellenirken bir hata oluştu.", "accessApprovalErrorUpdate": "Onay işlenemedi", "accessApprovalErrorUpdateDescription": "Onay işlenirken bir hata oluştu.", "accessRoleErrorNewRequired": "Yeni rol gerekli", "accessRoleErrorRemove": "Rol kaldırılamadı", "accessRoleErrorRemoveDescription": "Rol kaldırılırken bir hata oluştu.", "accessRoleName": "Rol Adı", "accessRoleQuestionRemove": "{name} rolünü silmek üzeresiniz. Bu eylemi geri alamazsınız.", "accessRoleRemove": "Rolü Kaldır", "accessRoleRemoveDescription": "Kuruluştan bir rol kaldır", "accessRoleRemoveSubmit": "Rolü Kaldır", "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", "licenseTierProfessional": "Profesyonel Lisans", "licenseTierEnterprise": "Kurumsal Lisans", "licenseTierPersonal": "Kişisel Lisans", "licensed": "Lisanslı", "yes": "Evet", "no": "Hayır", "sitesAdditional": "Ek Siteler", "licenseKeys": "Lisans Anahtarları", "sitestCountDecrease": "Site sayısını azalt", "sitestCountIncrease": "Site sayısını artır", "idpManage": "Kimlik Sağlayıcılarını Yönet", "idpManageDescription": "Sistem içindeki kimlik sağlayıcıları görün ve yönetin", "idpGlobalModeBanner": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri yönetici panelinde yönetin. Örgüt başına IdP'leri etkinleştirmek için, sunucu yapılandırmasını düzenleyin ve IdP modunu 'org' olarak ayarlayın. Belgeleri inceleyin . Küresel IdP'leri kullanmaya devam etmek istiyorsanız ve bunun örgüt ayarlarından kaybolmasını istiyorsanız, yapılandırmada modu otomatik olarak 'global' olarak ayarlayın.", "idpGlobalModeBannerUpgradeRequired": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri yönetici panelinde yönetin. Örgüt başına kimlik sağlayıcılar kullanmak için, Enterprise sürümüne yükseltmeniz gerekmektedir.", "idpGlobalModeBannerLicenseRequired": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri yönetici panelinde yönetin. Örgüt başına kimlik sağlayıcılar kullanmak için Enterprise lisansı gereklidir.", "idpDeletedDescription": "Kimlik sağlayıcı başarıyla silindi", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Kimlik sağlayıcısını kalıcı olarak silmek istediğinizden emin misiniz?", "idpMessageRemove": "Bu, kimlik sağlayıcıyı ve tüm ilişkili yapılandırmaları kaldıracaktır. Bu sağlayıcıdan kimlik doğrulayan kullanıcılar artık giriş yapamayacaktır.", "idpMessageConfirm": "Onaylamak için lütfen aşağıya kimlik sağlayıcının adını yazın.", "idpConfirmDelete": "Kimlik Sağlayıcıyı Silme Onayı", "idpDelete": "Kimlik Sağlayıcıyı Sil", "idp": "Kimlik Sağlayıcıları", "idpSearch": "Kimlik sağlayıcıları ara...", "idpAdd": "Kimlik Sağlayıcı Ekle", "idpClientIdRequired": "Müşteri Kimliği gereklidir.", "idpClientSecretRequired": "Müşteri Gizli Anahtarı gereklidir.", "idpErrorAuthUrlInvalid": "Kimlik Doğrulama URL'si geçerli bir URL olmalıdır.", "idpErrorTokenUrlInvalid": "Token URL'si geçerli bir URL olmalıdır.", "idpPathRequired": "Tanımlayıcı Yol gereklidir.", "idpScopeRequired": "Kapsamlar gereklidir.", "idpOidcDescription": "OpenID Connect kimlik sağlayıcısı yapılandırın", "idpCreatedDescription": "Kimlik sağlayıcı başarıyla oluşturuldu", "idpCreate": "Kimlik Sağlayıcı Oluştur", "idpCreateDescription": "Kullanıcı kimlik doğrulaması için yeni bir kimlik sağlayıcı yapılandırın", "idpSeeAll": "Tüm Kimlik Sağlayıcılarını Gör", "idpSettingsDescription": "Kimlik sağlayıcınız için temel bilgileri yapılandırın", "idpDisplayName": "Bu kimlik sağlayıcı için bir görüntü adı", "idpAutoProvisionUsers": "Kullanıcıları Otomatik Sağla", "idpAutoProvisionUsersDescription": "Etkinleştirildiğinde, kullanıcılar rol ve organizasyonlara eşleme yeteneğiyle birlikte sistemde otomatik olarak oluşturulacak.", "licenseBadge": " ", "idpType": "Sağlayıcı Türü", "idpTypeDescription": "Yapılandırmak istediğiniz kimlik sağlayıcısı türünü seçin", "idpOidcConfigure": "OAuth2/OIDC Yapılandırması", "idpOidcConfigureDescription": "OAuth2/OIDC sağlayıcı uç noktalarını ve kimlik bilgilerini yapılandırın", "idpClientId": "Müşteri ID", "idpClientIdDescription": "Kimlik sağlayıcınızdan alınan OAuth2 istemci kimliği", "idpClientSecret": "Müşteri Gizli", "idpClientSecretDescription": "Kimlik sağlayıcınızdan alınan OAuth2 istemci sırrı", "idpAuthUrl": "Yetki URL'si", "idpAuthUrlDescription": "OAuth2 yetki uç nokta URL'si", "idpTokenUrl": "Token URL'si", "idpTokenUrlDescription": "OAuth2 jeton uç nokta URL'si", "idpOidcConfigureAlert": "Önemli Bilgi", "idpOidcConfigureAlertDescription": "Kimlik sağlayıcısını oluşturduktan sonra, kimlik sağlayıcınızın ayarlarında geri arama URL'sini yapılandırmanız gerekecektir. Geri arama URL'si başarılı bir oluşturma işleminden sonra sağlanacaktır.", "idpToken": "Token Yapılandırma", "idpTokenDescription": "Kullanıcı bilgisini ID token'dan nasıl çıkaracağınızı yapılandırın", "idpJmespathAbout": "JMESPath Hakkında", "idpJmespathAboutDescription": "Aşağıdaki yollar, ID token'dan değerleri çıkarmak için JMESPath sözdizimini kullanır.", "idpJmespathAboutDescriptionLink": "JMESPath hakkında daha fazla bilgi edinin", "idpJmespathLabel": "Tanımlayıcı Yolu", "idpJmespathLabelDescription": "The JMESPath to the user identifier in the ID token", "idpJmespathEmailPathOptional": "E-posta Yolu (İsteğe Bağlı)", "idpJmespathEmailPathOptionalDescription": "The JMESPath to the user's email in the ID token", "idpJmespathNamePathOptional": "Ad Yolu (İsteğe Bağlı)", "idpJmespathNamePathOptionalDescription": "The JMESPath to the user's name in the ID token", "idpOidcConfigureScopes": "Kapsamlar", "idpOidcConfigureScopesDescription": "Talep edilecek OAuth2 kapsamlarının boşlukla ayrılmış listesi", "idpSubmit": "Kimlik Sağlayıcı Oluştur", "orgPolicies": "Kuruluş Politikaları", "idpSettings": "{idpName} Ayarları", "idpCreateSettingsDescription": "Kimlik sağlayıcınız için ayarları yapılandırın", "roleMapping": "Rol Eşlemesi", "orgMapping": "Kuruluş Eşlemesi", "orgPoliciesSearch": "Kuruluş politikalarını ara...", "orgPoliciesAdd": "Kuruluş Politikası Ekle", "orgRequired": "Kuruluş gereklidir", "error": "Hata", "success": "Başarı", "orgPolicyAddedDescription": "Politika başarıyla eklendi", "orgPolicyUpdatedDescription": "Politika başarıyla güncellendi", "orgPolicyDeletedDescription": "Politika başarıyla silindi", "defaultMappingsUpdatedDescription": "Varsayılan eşlemeler başarıyla güncellendi", "orgPoliciesAbout": "Kuruluş Politikaları Hakkında", "orgPoliciesAboutDescription": "Organization policies are used to control access to organizations based on the user's ID token. You can specify JMESPath expressions to extract role and organization information from the ID token. For more information, see", "orgPoliciesAboutDescriptionLink": "the documentation", "defaultMappingsOptional": "Varsayılan Eşlemeler (İsteğe Bağlı)", "defaultMappingsOptionalDescription": "Varsayılan eşlemeler, bir kuruluş için bir kuruluş politikası tanımlı olmadığında kullanılır. Burada varsayılan rol ve kuruluş eşlemelerini belirtebilirsiniz.", "defaultMappingsRole": "Varsayılan Rol Eşleme", "defaultMappingsRoleDescription": "JMESPath to extract role information from the ID token. The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsOrg": "Varsayılan Kuruluş Eşleme", "defaultMappingsOrgDescription": "JMESPath to extract organization information from the ID token. This expression must return the org ID or true for the user to be allowed to access the organization.", "defaultMappingsSubmit": "Varsayılan Eşlemeleri Kaydet", "orgPoliciesEdit": "Kuruluş Politikasını Düzenle", "org": "Kuruluş", "orgSelect": "Kuruluşu seç", "orgSearch": "Kuruluşu ara", "orgNotFound": "Kuruluş bulunamadı.", "roleMappingPathOptional": "Rol Eşleme Yolu (İsteğe Bağlı)", "orgMappingPathOptional": "Kuruluş Eşleme Yolu (İsteğe Bağlı)", "orgPolicyUpdate": "Politikayı Güncelle", "orgPolicyAdd": "Politika Ekle", "orgPolicyConfig": "Bir kuruluş için erişimi yapılandırın", "idpUpdatedDescription": "Kimlik sağlayıcı başarıyla güncellendi", "redirectUrl": "Yönlendirme URL'si", "orgIdpRedirectUrls": "Yönlendirme URL'leri", "redirectUrlAbout": "Yönlendirme URL'si Hakkında", "redirectUrlAboutDescription": "Bu, kimlik doğrulamasından sonra kullanıcıların yönlendirileceği URL'dir. Bu URL'yi kimlik sağlayıcınızın ayarlarında yapılandırmanız gerekir.", "pangolinAuth": "Yetkilendirme - Pangolin", "verificationCodeLengthRequirements": "Doğrulama kodunuz 8 karakter olmalıdır.", "errorOccurred": "Bir hata oluştu", "emailErrorVerify": "E-posta doğrulanamadı: ", "emailVerified": "E-posta başarıyla doğrulandı! Yönlendiriliyorsunuz...", "verificationCodeErrorResend": "Doğrulama kodu yeniden gönderilemedi:", "verificationCodeResend": "Doğrulama kodu yeniden gönderildi", "verificationCodeResendDescription": "E-posta adresinize bir doğrulama kodu yeniden gönderdik. Lütfen gelen kutunuzu kontrol edin.", "emailVerify": "E-posta Onayla", "emailVerifyDescription": "E-posta adresinize gönderilen doğrulama kodunu girin.", "verificationCode": "Doğrulama Kodu", "verificationCodeEmailSent": "E-posta adresinize bir doğrulama kodu gönderdik.", "submit": "Gönder", "emailVerifyResendProgress": "Yeniden gönderiliyor...", "emailVerifyResend": "Kod gelmedi mi? Tekrar göndermek için buraya tıklayın", "passwordNotMatch": "Parolalar eşleşmiyor", "signupError": "Kaydolurken bir hata oluştu", "pangolinLogoAlt": "Pangolin Logosu", "inviteAlready": "Davetiye gönderilmiş gibi görünüyor!", "inviteAlreadyDescription": "Daveti kabul etmek için giriş yapmalı veya bir hesap oluşturmalısınız.", "signupQuestion": "Zaten bir hesabınız var mı?", "login": "Giriş Yap", "resourceNotFound": "No resources found", "resourceNotFoundDescription": "Erişmeye çalıştığınız kaynak mevcut değil.", "pincodeRequirementsLength": "PIN kesinlikle 6 haneli olmalıdır", "pincodeRequirementsChars": "PIN sadece numaralardan oluşmalıdır", "passwordRequirementsLength": "Şifre en az 1 karakter uzunluğunda olmalıdır", "passwordRequirementsTitle": "Şifre gereksinimleri:", "passwordRequirementLength": "En az 8 karakter uzunluğunda", "passwordRequirementUppercase": "En az bir büyük harf", "passwordRequirementLowercase": "En az bir küçük harf", "passwordRequirementNumber": "En az bir sayı", "passwordRequirementSpecial": "En az bir özel karakter", "passwordRequirementsMet": "✓ Şifre tüm gereksinimleri karşılıyor", "passwordStrength": "Şifre gücü", "passwordStrengthWeak": "Zayıf", "passwordStrengthMedium": "Orta", "passwordStrengthStrong": "Güçlü", "passwordRequirements": "Gereksinimler:", "passwordRequirementLengthText": "8+ karakter", "passwordRequirementUppercaseText": "Büyük harf (A-Z)", "passwordRequirementLowercaseText": "Küçük harf (a-z)", "passwordRequirementNumberText": "Sayı (0-9)", "passwordRequirementSpecialText": "Özel karakter (!@#$%...)", "passwordsDoNotMatch": "Parolalar eşleşmiyor", "otpEmailRequirementsLength": "OTP en az 1 karakter uzunluğunda olmalıdır", "otpEmailSent": "OTP Gönderildi", "otpEmailSentDescription": "E-posta adresinize bir OTP gönderildi", "otpEmailErrorAuthenticate": "E-posta ile kimlik doğrulama başarasız oldu", "pincodeErrorAuthenticate": "PIN kodu ile kimlik doğrulama başarısız oldu", "passwordErrorAuthenticate": "Şifre ile kimlik doğrulama başarısız oldu", "poweredBy": "Tarafından sağlanmıştır", "authenticationRequired": "Kimlik Doğrulama Gerekiyor", "authenticationMethodChoose": "{name} erişimi için tercih edilen yöntemi seçin", "authenticationRequest": "{name} erişimi için kimlik doğrulamanız gerekiyor", "user": "Kullanıcı", "pincodeInput": "6 haneli PIN Kodu", "pincodeSubmit": "PIN ile Giriş Yap", "passwordSubmit": "Şifre ile Giriş Yap", "otpEmailDescription": "Bu e-posta adresine tek kullanımlık bir kod gönderilecektir.", "otpEmailSend": "Tek Kullanımlık Kod Gönder", "otpEmail": "Tek Kullanımlık Parola (OTP)", "otpEmailSubmit": "OTP Gönder", "backToEmail": "E-postaya Geri Dön", "noSupportKey": "Sunucu destek anahtarı olmadan çalışıyor. Projeyi desteklemeyi düşünün!", "accessDenied": "Erişim Reddedildi", "accessDeniedDescription": "Bu kaynağa erişim izniniz yok. Bunun bir hata olduğunu düşünüyorsanız lütfen yöneticiyle iletişime geçin.", "accessTokenError": "Erişim jetonu kontrol ederken hata oluştu", "accessGranted": "Erişim İzni Verildi", "accessUrlInvalid": "Erişim URL'si Geçersiz", "accessGrantedDescription": "Bu kaynağa erişim izni verildi. Yönlendiriliyorsunuz...", "accessUrlInvalidDescription": "Bu paylaşılan erişim URL'si geçersiz. Yeni bir URL için lütfen kaynak sahibine başvurun.", "tokenInvalid": "Geçersiz jeton", "pincodeInvalid": "Geçersiz kod", "passwordErrorRequestReset": "Şifre sıfırlama isteği başarısız oldu:", "passwordErrorReset": "Şifre sıfırlama başarısız oldu:", "passwordResetSuccess": "Şifre başarıyla sıfırlandı! Girişe geri...", "passwordReset": "Şifreyi Yenile", "passwordResetDescription": "Şifrenizi sıfırlamak için adımları uygulayın", "passwordResetSent": "Bu e-posta adresine bir şifre sıfırlama kodu gönderilecektir.", "passwordResetCode": "Sıfırlama Kodu", "passwordResetCodeDescription": "E-posta gelen kutunuzda sıfırlama kodunu kontrol edin.", "generatePasswordResetCode": "Parola Sıfırlama Kodunu Oluştur", "passwordResetCodeGenerated": "Parola Sıfırlama Kodu Oluşturuldu", "passwordResetCodeGeneratedDescription": "Bu kodu kullanıcı ile paylaşın. Parolalarını sıfırlamak için bunu kullanabilirler.", "passwordResetUrl": "Parola Sıfırlama URL'si", "passwordNew": "Yeni Şifre", "passwordNewConfirm": "Yeni Şifreyi Onayla", "changePassword": "Parola Değiştir", "changePasswordDescription": "Hesap şifrenizi güncelleyin", "oldPassword": "Mevcut Şifre", "newPassword": "Yeni Şifre", "confirmNewPassword": "Yeni Şifreyi Onayla", "changePasswordError": "Parola değiştirme başarısız oldu", "changePasswordErrorDescription": "Parolanız değiştiriliyor.", "changePasswordSuccess": "Şifre Başarıyla Değiştirildi", "changePasswordSuccessDescription": "Parolanız başarıyla güncellendi", "passwordExpiryRequired": "Şifre Süresi Gereklidir", "passwordExpiryDescription": "Bu kuruluş, parolanızı {maxDays} günde bir değiştirmenizi gerektirir.", "changePasswordNow": "Şifrenizi Şimdi Değiştirin", "pincodeAuth": "Kimlik Doğrulama Kodu", "pincodeSubmit2": "Kodu Gönder", "passwordResetSubmit": "Sıfırlama İsteği", "passwordResetAlreadyHaveCode": "Kodu Girin", "passwordResetSmtpRequired": "Yönetici ile iletişime geçin", "passwordResetSmtpRequiredDescription": "Parolanızı sıfırlamak için bir parola sıfırlama kodu gereklidir. Yardım için yönetici ile iletişime geçin.", "passwordBack": "Şifreye Geri Dön", "loginBack": "Ana oturum açma sayfasına geri dön", "signup": "Kaydol", "loginStart": "Başlamak için giriş yapın", "idpOidcTokenValidating": "OIDC token'ı doğrulanıyor", "idpOidcTokenResponse": "OIDC token yanıtını doğrula", "idpErrorOidcTokenValidating": "OIDC token'ı doğrularken hata", "idpConnectingTo": "{name} ile bağlantı kuruluyor", "idpConnectingToDescription": "Kimliğiniz doğrulanıyor", "idpConnectingToProcess": "Bağlanılıyor...", "idpConnectingToFinished": "Bağlandı", "idpErrorConnectingTo": "{name} ile bağlantı kurarken bir sorun meydana geldi. Lütfen yöneticiye danışın.", "idpErrorNotFound": "IdP bulunamadı", "inviteInvalid": "Geçersiz Davet", "inviteInvalidDescription": "Davet bağlantısı geçersiz.", "inviteErrorWrongUser": "Davet bu kullanıcı için değil", "inviteErrorUserNotExists": "Kullanıcı mevcut değil. Lütfen önce bir hesap oluşturun.", "inviteErrorLoginRequired": "Bir daveti kabul etmek için giriş yapmış olmanız gerekir", "inviteErrorExpired": "Davet süresi dolmuş olabilir", "inviteErrorRevoked": "Davet iptal edilmiş olabilir", "inviteErrorTypo": "Davet bağlantısında yazım hatası olabilir", "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.", "overview": "Genel Bakış", "home": "Ana Sayfa", "settings": "Ayarlar", "usersAll": "Tüm Kullanıcılar", "license": "Lisans", "pangolinDashboard": "Kontrol Paneli - Pangolin", "noResults": "Sonuç bulunamadı.", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "Girilen Etiketler", "tagsEnteredDescription": "Bunlar girilen etiketlerdir.", "tagsWarnCannotBeLessThanZero": "maxTags ve minTags 0'ın altında olamaz", "tagsWarnNotAllowedAutocompleteOptions": "Otomatik tamamlama seçeneklerine göre etiket izin verilmiyor", "tagsWarnInvalid": "validateTag'e göre geçersiz etiket", "tagWarnTooShort": "Etiket {tagText} çok kısa", "tagWarnTooLong": "Etiket {tagText} çok uzun", "tagsWarnReachedMaxNumber": "İzin verilen maksimum etiket sayısına ulaşıldı", "tagWarnDuplicate": "Yinelenen etiket {tagText} eklenmedi", "supportKeyInvalid": "Geçersiz Anahtar", "supportKeyInvalidDescription": "Destekleyici anahtarınız geçersiz.", "supportKeyValid": "Geçerli Anahtar", "supportKeyValidDescription": "Destekleyici anahtarınız doğrulandı. Desteğiniz için teşekkürler!", "supportKeyErrorValidationDescription": "Destekleyici anahtar doğrulanamadı.", "supportKey": "Geliştirmeyi Destekleyin ve Bir Pangolin Edinin!", "supportKeyDescription": "Pangolin uygulamasını topluluk için geliştirmemize devam etmemize yardımcı olacak bir destek anahtarı satın alın. Katkınız, herkese uygulamanın bakımını yapmamıza ve yeni özellikler eklememize daha fazla zaman ayırmamıza olanak tanır. Bu özellikleri ücretli hale getirmek için kullanılmayacaktır. Bu durum Ticari Sürümden tamamen ayrıdır.", "supportKeyPet": "Ayrıca kendi evcil Pangolininize sahip olacak ve onunla tanışacaksınız!", "supportKeyPurchase": "Ödemeler GitHub üzerinden işlenir. Daha sonra anahtarınızı şu yerden alabilirsiniz:", "supportKeyPurchaseLink": "web sitemiz", "supportKeyPurchase2": "ve burada kullanabilirsiniz.", "supportKeyLearnMore": "Daha fazla bilgi.", "supportKeyOptions": "Size en uygun seçeneği lütfen seçin.", "supportKetOptionFull": "Tam Destek", "forWholeServer": "Tüm sunucu için", "lifetimePurchase": "Ömür Boyu satın alma", "supporterStatus": "Destekçi durumu", "buy": "Satın Al", "supportKeyOptionLimited": "Sınırlı Destek", "forFiveUsers": "5 veya daha az kullanıcı için", "supportKeyRedeem": "Destekleyici Anahtarı Gir", "supportKeyHideSevenDays": "7 gün boyunca gizle", "supportKeyEnter": "Destekçi Anahtarını Gir", "supportKeyEnterDescription": "Kendi evcil Pangolininle tanış!", "githubUsername": "GitHub Kullanıcı Adı", "supportKeyInput": "Destekçi Anahtarı", "supportKeyBuy": "Destekçi Anahtarı Satın Al", "logoutError": "Çıkış yaparken hata", "signingAs": "Olarak giriş yapıldı", "serverAdmin": "Sunucu Yöneticisi", "managedSelfhosted": "Yönetilen Self-Hosted", "otpEnable": "İki faktörlü özelliğini etkinleştir", "otpDisable": "İki faktörlü özelliğini devre dışı bırak", "logout": "Çıkış Yap", "licenseTierProfessionalRequired": "Profesyonel Sürüme Gereklidir", "licenseTierProfessionalRequiredDescription": "Bu özellik yalnızca Professional Edition'da kullanılabilir.", "actionGetOrg": "Kuruluşu Al", "updateOrgUser": "Organizasyon Kullanıcısını Güncelle", "createOrgUser": "Organizasyon Kullanıcısı Oluştur", "actionUpdateOrg": "Kuruluşu Güncelle", "actionRemoveInvitation": "Daveti Kaldır", "actionUpdateUser": "Kullanıcıyı Güncelle", "actionGetUser": "Kullanıcıyı Getir", "actionGetOrgUser": "Kuruluş Kullanıcısını Al", "actionListOrgDomains": "Kuruluş Alan Adlarını Listele", "actionGetDomain": "Alan Adını Al", "actionCreateOrgDomain": "Alan Adı Oluştur", "actionUpdateOrgDomain": "Alan Adını Güncelle", "actionDeleteOrgDomain": "Alan Adını Sil", "actionGetDNSRecords": "DNS Kayıtlarını Al", "actionRestartOrgDomain": "Alanı Yeniden Başlat", "actionCreateSite": "Site Oluştur", "actionDeleteSite": "Siteyi Sil", "actionGetSite": "Siteyi Al", "actionListSites": "Siteleri Listele", "actionApplyBlueprint": "Planı Uygula", "actionListBlueprints": "Plan Listesini Görüntüle", "actionGetBlueprint": "Planı Elde Et", "setupToken": "Kurulum Simgesi", "setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.", "setupTokenRequired": "Kurulum simgesi gerekli", "actionUpdateSite": "Siteyi Güncelle", "actionListSiteRoles": "İzin Verilen Site Rolleri Listele", "actionCreateResource": "Kaynak Oluştur", "actionDeleteResource": "Kaynağı Sil", "actionGetResource": "Kaynağı Al", "actionListResource": "Kaynakları Listele", "actionUpdateResource": "Kaynağı Güncelle", "actionListResourceUsers": "Kaynak Kullanıcılarını Listele", "actionSetResourceUsers": "Kaynak Kullanıcılarını Ayarla", "actionSetAllowedResourceRoles": "İzin Verilen Kaynak Rolleri Ayarla", "actionListAllowedResourceRoles": "İzin Verilen Kaynak Rolleri Listele", "actionSetResourcePassword": "Kaynak Şifresini Ayarla", "actionSetResourcePincode": "Kaynak PIN Kodunu Ayarla", "actionSetResourceEmailWhitelist": "Kaynak E-posta Beyaz Listesi Ayarla", "actionGetResourceEmailWhitelist": "Kaynak E-posta Beyaz Listesini Al", "actionCreateTarget": "Hedef Oluştur", "actionDeleteTarget": "Hedefi Sil", "actionGetTarget": "Hedefi Al", "actionListTargets": "Hedefleri Listele", "actionUpdateTarget": "Hedefi Güncelle", "actionCreateRole": "Rol Oluştur", "actionDeleteRole": "Rolü Sil", "actionGetRole": "Rolü Al", "actionListRole": "Rolleri Listele", "actionUpdateRole": "Rolü Güncelle", "actionListAllowedRoleResources": "İzin Verilen Rol Kaynakları Listele", "actionInviteUser": "Kullanıcıyı Davet Et", "actionRemoveUser": "Kullanıcıyı Kaldır", "actionListUsers": "Kullanıcıları Listele", "actionAddUserRole": "Kullanıcı Rolü Ekle", "actionGenerateAccessToken": "Erişim Jetonu Oluştur", "actionDeleteAccessToken": "Erişim Jetonunu Sil", "actionListAccessTokens": "Erişim Jetonlarını Listele", "actionCreateResourceRule": "Kaynak Kuralı Oluştur", "actionDeleteResourceRule": "Kaynak Kuralını Sil", "actionListResourceRules": "Kaynak Kurallarını Listele", "actionUpdateResourceRule": "Kaynak Kuralını Güncelle", "actionListOrgs": "Organizasyonları Listele", "actionCheckOrgId": "Kimliği Kontrol Et", "actionCreateOrg": "Organizasyon Oluştur", "actionDeleteOrg": "Organizasyonu Sil", "actionListApiKeys": "API Anahtarlarını Listele", "actionListApiKeyActions": "API Anahtarı İşlemlerini Listele", "actionSetApiKeyActions": "API Anahtarı İzin Verilen İşlemleri Ayarla", "actionCreateApiKey": "API Anahtarı Oluştur", "actionDeleteApiKey": "API Anahtarını Sil", "actionCreateIdp": "Kimlik Sağlayıcı Oluştur", "actionUpdateIdp": "Kimlik Sağlayıcıyı Güncelle", "actionDeleteIdp": "Kimlik Sağlayıcıyı Sil", "actionListIdps": "Kimlik Sağlayıcı Listesi", "actionGetIdp": "Kimlik Sağlayıcıyı Getir", "actionCreateIdpOrg": "Kimlik Sağlayıcı Organizasyon Politikasını Oluştur", "actionDeleteIdpOrg": "Kimlik Sağlayıcı Organizasyon Politikasını Sil", "actionListIdpOrgs": "Kimlik Sağlayıcı Organizasyonları Listele", "actionUpdateIdpOrg": "Kimlik Sağlayıcı Organizasyonu Güncelle", "actionCreateClient": "Müşteri Oluştur", "actionDeleteClient": "Müşteri Sil", "actionArchiveClient": "İstemci Arşivle", "actionUnarchiveClient": "İstemci Arşivini Kaldır", "actionBlockClient": "İstemci Engelle", "actionUnblockClient": "İstemci Engelini Kaldır", "actionUpdateClient": "Müşteri Güncelle", "actionListClients": "Müşterileri Listele", "actionGetClient": "Müşteriyi Al", "actionCreateSiteResource": "Site Kaynağı Oluştur", "actionDeleteSiteResource": "Site Kaynağını Sil", "actionGetSiteResource": "Site Kaynağını Al", "actionListSiteResources": "Site Kaynaklarını Listele", "actionUpdateSiteResource": "Site Kaynağını Güncelle", "actionListInvitations": "Davetiyeleri Listele", "actionExportLogs": "Kayıtları Dışa Aktar", "actionViewLogs": "Kayıtları Görüntüle", "noneSelected": "Hiçbiri seçili değil", "orgNotFound2": "Hiçbir organizasyon bulunamadı.", "searchPlaceholder": "Ara...", "emptySearchOptions": "Seçenek bulunamadı", "create": "Oluştur", "orgs": "Organizasyonlar", "loginError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", "loginRequiredForDevice": "Cihazınız için oturum açmanız gerekiyor.", "passwordForgot": "Şifrenizi mi unuttunuz?", "otpAuth": "İki Faktörlü Kimlik Doğrulama", "otpAuthDescription": "Authenticator uygulamanızdan veya tek kullanımlık yedek kodlarınızdan birini girin.", "otpAuthSubmit": "Kodu Gönder", "idpContinue": "Veya devam et:", "otpAuthBack": "Şifreye Geri Dön", "navbar": "Navigasyon Menüsü", "navbarDescription": "Uygulamanın ana navigasyon menüsü", "navbarDocsLink": "Dokümantasyon", "otpErrorEnable": "2FA etkinleştirilemedi", "otpErrorEnableDescription": "2FA etkinleştirilirken bir hata oluştu", "otpSetupCheckCode": "6 haneli bir kod girin", "otpSetupCheckCodeRetry": "Geçersiz kod. Lütfen tekrar deneyin.", "otpSetup": "İki Faktörlü Kimlik Doğrulamayı Etkinleştir", "otpSetupDescription": "Hesabınızı ekstra bir koruma katmanıyla güvence altına alın", "otpSetupScanQr": "Authenticator uygulamanızla bu QR kodunu tarayın veya gizli anahtarı manuel olarak girin:", "otpSetupSecretCode": "Kimlik Doğrulayıcı Kodu", "otpSetupSuccess": "İki Faktörlü Kimlik Doğrulama Etkinleştirildi", "otpSetupSuccessStoreBackupCodes": "Hesabınız artık daha güvenli. Yedek kodlarınızı kaydetmeyi unutmayın.", "otpErrorDisable": "2FA devre dışı bırakılamadı", "otpErrorDisableDescription": "2FA devre dışı bırakılırken bir hata oluştu", "otpRemove": "İki Faktörlü Kimlik Doğrulamayı Devre Dışı Bırak", "otpRemoveDescription": "Hesabınız için iki faktörlü kimlik doğrulamayı devre dışı bırakın", "otpRemoveSuccess": "İki Faktörlü Kimlik Doğrulama Devre Dışı", "otpRemoveSuccessMessage": "Hesabınız için iki faktörlü kimlik doğrulama devre dışı bırakıldı. İstediğiniz zaman tekrar etkinleştirebilirsiniz.", "otpRemoveSubmit": "2FA'yı Devre Dışı Bırak", "paginator": "Sayfa {current} / {last}", "paginatorToFirst": "İlk sayfaya git", "paginatorToPrevious": "Önceki sayfaya git", "paginatorToNext": "Sonraki sayfaya git", "paginatorToLast": "Son sayfaya git", "copyText": "Metni kopyala", "copyTextFailed": "Metin kopyalanamadı: ", "copyTextClipboard": "Panoya kopyala", "inviteErrorInvalidConfirmation": "Geçersiz onay", "passwordRequired": "Şifre gerekli", "allowAll": "Tümüne İzin Ver", "permissionsAllowAll": "Tüm İzinlere İzin Ver", "githubUsernameRequired": "GitHub kullanıcı adı gereklidir", "supportKeyRequired": "Destekleyici anahtar gereklidir", "passwordRequirementsChars": "Şifre en az 8 karakter olmalıdır", "language": "Dil", "verificationCodeRequired": "Kod gerekli", "userErrorNoUpdate": "Güncellenecek kullanıcı yok", "siteErrorNoUpdate": "Güncellenecek site yok", "resourceErrorNoUpdate": "Güncellenecek kaynak yok", "authErrorNoUpdate": "Güncellenecek kimlik doğrulama bilgisi yok", "orgErrorNoUpdate": "Güncellenecek organizasyon yok", "orgErrorNoProvided": "Sağlanan organizasyon yok", "apiKeysErrorNoUpdate": "Güncellenecek API anahtarı yok", "sidebarOverview": "Genel Bakış", "sidebarHome": "Ana Sayfa", "sidebarSites": "Siteler", "sidebarApprovals": "Onay Talepleri", "sidebarResources": "Kaynaklar", "sidebarProxyResources": "Herkese Açık", "sidebarClientResources": "Özel", "sidebarAccessControl": "Erişim Kontrolü", "sidebarLogsAndAnalytics": "Kayıtlar & Analitik", "sidebarTeam": "Ekip", "sidebarUsers": "Kullanıcılar", "sidebarAdmin": "Yönetici", "sidebarInvitations": "Davetiye", "sidebarRoles": "Roller", "sidebarShareableLinks": "Bağlantılar", "sidebarApiKeys": "API Anahtarları", "sidebarSettings": "Ayarlar", "sidebarAllUsers": "Tüm Kullanıcılar", "sidebarIdentityProviders": "Kimlik Sağlayıcılar", "sidebarLicense": "Lisans", "sidebarClients": "İstemciler", "sidebarUserDevices": "Kullanıcı Cihazları", "sidebarMachineClients": "Makineler", "sidebarDomains": "Alan Adları", "sidebarGeneral": "Yönet", "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", "blueprintAdd": "Plan Ekle", "blueprintGoBack": "Tüm Planları Gör", "blueprintCreate": "Plan Oluştur", "blueprintCreateDescription2": "Yeni bir plan oluşturup uygulamak için aşağıdaki adımları izleyin", "blueprintDetails": "Mavi Yazılım Detayları", "blueprintDetailsDescription": "Uygulanan mavi yazılımın sonucunu ve oluşan hataları görün", "blueprintInfo": "Plan Bilgileri", "message": "Mesaj", "blueprintContentsDescription": "Altyapıyı tanımlayan YAML içeriğini belirleyin", "blueprintErrorCreateDescription": "Plan uygulanırken bir hata oluştu", "blueprintErrorCreate": "Plan oluşturulurken hata oluştu", "searchBlueprintProgress": "Planlarda ara...", "appliedAt": "Uygulama Zamanı", "source": "Kaynak", "contents": "İçerik", "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.", "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.", "containerName": "Ad", "containerImage": "Görsel", "containerState": "Durum", "containerNetworks": "Ağlar", "containerHostnameIp": "Ana Makine/IP", "containerLabels": "Etiketler", "containerLabelsCount": "{count, plural, one {# etiket} other {# etiketler}}", "containerLabelsTitle": "Konteyner Etiketleri", "containerLabelEmpty": "", "containerPorts": "Bağlantı Noktaları", "containerPortsMore": "+{count} tane daha", "containerActions": "İşlemler", "select": "Seç", "noContainersMatchingFilters": "Mevcut filtrelerle uyuşan konteyner bulunamadı.", "showContainersWithoutPorts": "Bağlantı noktası olmayan konteynerleri göster", "showStoppedContainers": "Durdurulmuş konteynerleri göster", "noContainersFound": "Konteyner bulunamadı. Docker konteynerlerinin çalıştığından emin olun.", "searchContainersPlaceholder": "{count} konteyner arasında arama yapın...", "searchResultsCount": "{count, plural, one {# sonuç} other {# sonuçlar}}", "filters": "Filtreler", "filterOptions": "Filtre Seçenekleri", "filterPorts": "Bağlantı Noktaları", "filterStopped": "Durdurulanlar", "clearAllFilters": "Tüm filtreleri temizle", "columns": "Sütunlar", "toggleColumns": "Sütunları Aç/Kapat", "refreshContainersList": "Konteyner listesi yenile", "searching": "Aranıyor...", "noContainersFoundMatching": "\"{filter}\" ile eşleşen konteyner bulunamadı.", "light": "açık", "dark": "koyu", "system": "sistem", "theme": "Tema", "subnetRequired": "Alt ağ gereklidir", "initialSetupTitle": "İlk Sunucu Kurulumu", "initialSetupDescription": "İlk sunucu yönetici hesabını oluşturun. Yalnızca bir sunucu yöneticisi olabilir. Bu kimlik bilgilerini daha sonra her zaman değiştirebilirsiniz.", "createAdminAccount": "Yönetici Hesabı Oluştur", "setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.", "certificateStatus": "Sertifika Durumu", "loading": "Yükleniyor", "loadingAnalytics": "Analiz Yükleniyor", "restart": "Yeniden Başlat", "domains": "Alan Adları", "domainsDescription": "Organizasyonda kullanılabilir alan adlarını oluşturun ve yönetin", "domainsSearch": "Alan adlarını ara...", "domainAdd": "Alan Adı Ekle", "domainAddDescription": "Organizasyonunuz için yeni bir alan adı kaydedin", "domainCreate": "Alan Adı Oluştur", "domainCreatedDescription": "Alan adı başarıyla oluşturuldu", "domainDeletedDescription": "Alan adı başarıyla silindi", "domainQuestionRemove": "Alan adını kaldırmak istediğinizden emin misiniz?", "domainMessageRemove": "Kaldırıldığında, alan adı artık organizasyonunuzla ilişkilendirilmez.", "domainConfirmDelete": "Alan Adı Silinmesini Onayla", "domainDelete": "Alan Adını Sil", "domain": "Alan Adı", "selectDomainTypeNsName": "Alan Adı Delege Etme (NS)", "selectDomainTypeNsDescription": "Bu alan adı ve tüm alt alan adları. Tüm bir alan adı bölgesini kontrol etmek istediğinizde bunu kullanın.", "selectDomainTypeCnameName": "Tekil Alan Adı (CNAME)", "selectDomainTypeCnameDescription": "Sadece bu belirli alan adı. Bireysel alt alan adları veya belirli alan adı girişleri için bunu kullanın.", "selectDomainTypeWildcardName": "Wildcard Alan Adı", "selectDomainTypeWildcardDescription": "Bu domain ve alt alan adları.", "domainDelegation": "Tekil Alan Adı", "selectType": "Bir tür seçin", "actions": "İşlemler", "refresh": "Yenile", "refreshError": "Veriler yenilenemedi", "verified": "Doğrulandı", "pending": "Beklemede", "pendingApproval": "Bekleyen Onay", "sidebarBilling": "Faturalama", "billing": "Faturalama", "orgBillingDescription": "Fatura bilgilerinizi ve aboneliklerinizi yönetin", "github": "GitHub", "pangolinHosted": "Pangolin Barındırılan", "fossorial": "Fossorial", "completeAccountSetup": "Hesap Kurulumunu Tamamla", "completeAccountSetupDescription": "Başlamak için şifrenizi ayarlayın", "accountSetupSent": "Bu e-posta adresine bir hesap kurulum kodu göndereceğiz.", "accountSetupCode": "Kurulum Kodu", "accountSetupCodeDescription": "Kurulum kodu için e-posta gelen kutunuzu kontrol edin.", "passwordCreate": "Parola Oluştur", "passwordCreateConfirm": "Şifreyi Onayla", "accountSetupSubmit": "Kurulum Kodunu Gönder", "completeSetup": "Kurulumu Tamamla", "accountSetupSuccess": "Hesap kurulumu tamamlandı! Pangolin'e hoş geldiniz!", "documentation": "Dokümantasyon", "saveAllSettings": "Tüm Ayarları Kaydet", "saveResourceTargets": "Hedefleri Kaydet", "saveResourceHttp": "Proxy Ayarlarını Kaydet", "saveProxyProtocol": "Proxy protokol ayarlarını kaydet", "settingsUpdated": "Ayarlar güncellendi", "settingsUpdatedDescription": "Ayarlar başarıyla güncellendi", "settingsErrorUpdate": "Ayarlar güncellenemedi", "settingsErrorUpdateDescription": "Ayarları güncellerken bir hata oluştu", "sidebarCollapse": "Daralt", "sidebarExpand": "Genişlet", "productUpdateMoreInfo": "{noOfUpdates} daha fazla güncelleme", "productUpdateInfo": "{noOfUpdates} güncellemeler", "productUpdateWhatsNew": "Neler Yeni", "productUpdateTitle": "Ürün Güncellemeleri", "productUpdateEmpty": "Güncelleme yok", "dismissAll": "Hepsini Kapat", "pangolinUpdateAvailable": "Güncelleme Mevcut", "pangolinUpdateAvailableInfo": "Sürüm {version} yüklenmeye hazır", "pangolinUpdateAvailableReleaseNotes": "Yayın Notlarını Görüntüle", "newtUpdateAvailable": "Güncelleme Mevcut", "newtUpdateAvailableInfo": "Newt'in yeni bir versiyonu mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", "domainPickerEnterDomain": "Alan Adı", "domainPickerPlaceholder": "myapp.example.com", "domainPickerDescription": "Mevcut seçenekleri görmek için kaynağın tam etki alanını girin.", "domainPickerDescriptionSaas": "Mevcut seçenekleri görmek için tam etki alanı, alt etki alanı veya sadece bir isim girin", "domainPickerTabAll": "Tümü", "domainPickerTabOrganization": "Organizasyon", "domainPickerTabProvided": "Sağlanan", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "Kullanılabilirlik kontrol ediliyor...", "domainPickerNoMatchingDomains": "Eşleşen alan adı bulunamadı. Farklı bir alan adı deneyin veya organizasyonunuzun alan ayarlarını kontrol edin.", "domainPickerOrganizationDomains": "Organizasyon Alan Adları", "domainPickerProvidedDomains": "Sağlanan Alan Adları", "domainPickerSubdomain": "Alt Alan: {subdomain}", "domainPickerNamespace": "Ad Alanı: {namespace}", "domainPickerShowMore": "Daha Fazla Göster", "regionSelectorTitle": "Bölge Seç", "regionSelectorInfo": "Bir bölge seçmek, konumunuz için daha iyi performans sağlamamıza yardımcı olur. Sunucunuzla aynı bölgede olmanıza gerek yoktur.", "regionSelectorPlaceholder": "Bölge Seçin", "regionSelectorComingSoon": "Yakında Geliyor", "billingLoadingSubscription": "Abonelik yükleniyor...", "billingFreeTier": "Ücretsiz Dilim", "billingWarningOverLimit": "Uyarı: Bir veya daha fazla kullanım limitini aştınız. Aboneliğinizi değiştirmediğiniz veya kullanımı ayarlamadığınız sürece siteleriniz bağlanmayacaktır.", "billingUsageLimitsOverview": "Kullanım Limitleri Genel Görünümü", "billingMonitorUsage": "Kullanımınızı yapılandırılmış limitlerle karşılaştırın. Limitlerin artırılmasına ihtiyacınız varsa, lütfen support@pangolin.net adresinden bizimle iletişime geçin.", "billingDataUsage": "Veri Kullanımı", "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", "billingIncludedUsage": "Dahil Kullanım", "billingIncludedUsageDescription": "Mevcut abonelik planınıza bağlı kullanım", "billingFreeTierIncludedUsage": "Ücretsiz dilim kullanım hakları", "billingIncluded": "dahil", "billingEstimatedTotal": "Tahmini Toplam:", "billingNotes": "Notlar", "billingEstimateNote": "Bu, mevcut kullanımınıza dayalı bir tahmindir.", "billingActualChargesMayVary": "Asıl ücretler farklılık gösterebilir.", "billingBilledAtEnd": "Fatura döneminin sonunda fatura düzenlenecektir.", "billingModifySubscription": "Aboneliği Düzenle", "billingStartSubscription": "Aboneliği Başlat", "billingRecurringCharge": "Yinelenen Ücret", "billingManageSubscriptionSettings": "Abonelik ayarlarınızı ve tercihlerinizi yönetin", "billingNoActiveSubscription": "Aktif bir aboneliğiniz yok. Kullanım limitlerini artırmak için aboneliğinizi başlatın.", "billingFailedToLoadSubscription": "Abonelik yüklenemedi", "billingFailedToLoadUsage": "Kullanım yüklenemedi", "billingFailedToGetCheckoutUrl": "Ödeme URL'si alınamadı", "billingPleaseTryAgainLater": "Lütfen daha sonra tekrar deneyin.", "billingCheckoutError": "Ödeme Hatası", "billingFailedToGetPortalUrl": "Portal URL'si alınamadı", "billingPortalError": "Portal Hatası", "billingDataUsageInfo": "Buluta bağlandığınızda, güvenli tünellerinizden aktarılan tüm verilerden ücret alınırsınız. Bu, tüm sitelerinizdeki gelen ve giden trafiği içerir. Limitinize ulaştığınızda, planınızı yükseltmeli veya kullanımı azaltmalısınız, aksi takdirde siteleriniz bağlantıyı keser. Düğümler kullanırken verilerden ücret alınmaz.", "billingSInfo": "Kaç tane site kullanabileceğiniz", "billingUsersInfo": "Kaç tane kullanıcı kullanabileceğiniz", "billingDomainInfo": "Kaç tane alan adı kullanabileceğiniz", "billingRemoteExitNodesInfo": "Kaç tane uzaktan düğüm kullanabileceğiniz", "billingLicenseKeys": "Lisans Anahtarları", "billingLicenseKeysDescription": "Lisans anahtarı aboneliklerinizi yönetin", "billingLicenseSubscription": "Lisans Aboneliği", "billingInactive": "Pasif", "billingLicenseItem": "Lisans Öğesi", "billingQuantity": "Miktar", "billingTotal": "toplam", "billingModifyLicenses": "Lisans Aboneliğini Düzenle", "domainNotFound": "Alan Adı Bulunamadı", "domainNotFoundDescription": "Bu kaynak devre dışıdır çünkü alan adı sistemimizde artık mevcut değil. Bu kaynak için yeni bir alan adı belirleyin.", "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", "securityKeyRegister": "Yeni Güvenlik Anahtarı Kaydet", "securityKeyList": "Güvenlik Anahtarlarınız", "securityKeyNone": "Henüz kayıtlı güvenlik anahtarı yok", "securityKeyNameRequired": "İsim gerekli", "securityKeyRemove": "Kaldır", "securityKeyLastUsed": "Son kullanım: {date}", "securityKeyNameLabel": "İsim", "securityKeyRegisterSuccess": "Güvenlik anahtarı başarıyla kaydedildi", "securityKeyRegisterError": "Güvenlik anahtarı kaydedilirken hata oluştu", "securityKeyRemoveSuccess": "Güvenlik anahtarı başarıyla kaldırıldı", "securityKeyRemoveError": "Güvenlik anahtarı kaldırılırken hata oluştu", "securityKeyLoadError": "Güvenlik anahtarları yüklenirken hata oluştu", "securityKeyLogin": "Güvenlik Anahtarı Kullan", "securityKeyAuthError": "Güvenlik anahtarı ile kimlik doğrulama başarısız oldu", "securityKeyRecommendation": "Hesabınızdan kilitlenmediğinizden emin olmak için farklı bir cihazda başka bir güvenlik anahtarı kaydetmeyi düşünün.", "registering": "Kaydediliyor...", "securityKeyPrompt": "Lütfen güvenlik anahtarınızı kullanarak kimliğinizi doğrulayın. Güvenlik anahtarınızın bağlı ve hazır olduğundan emin olun.", "securityKeyBrowserNotSupported": "Tarayıcınız güvenlik anahtarlarını desteklemiyor. Lütfen Chrome, Firefox veya Safari gibi modern bir tarayıcı kullanın.", "securityKeyPermissionDenied": "Giriş yapmaya devam etmek için lütfen güvenlik anahtarınıza erişime izin verin.", "securityKeyRemovedTooQuickly": "Güvenlik anahtarınızın bağlantısını kesmeden önce oturum açma işlemi tamamlanana kadar bağlı kalmasını sağlayın.", "securityKeyNotSupported": "Güvenlik anahtarınız uyumlu olmayabilir. Lütfen farklı bir güvenlik anahtarı deneyin.", "securityKeyUnknownError": "Güvenlik anahtarınızı kullanırken bir sorun oluştu. Lütfen tekrar deneyin.", "twoFactorRequired": "Güvenlik anahtarını kaydetmek için iki faktörlü kimlik doğrulama gereklidir.", "twoFactor": "İki Faktörlü Kimlik Doğrulama", "twoFactorAuthentication": "İki Faktörlü Kimlik Doğrulama", "twoFactorDescription": "Bu kuruluş iki faktörlü kimlik doğrulama gerektirir.", "enableTwoFactor": "İki Faktörlü Kimlik Doğrulamayı Etkinleştir", "organizationSecurityPolicy": "Kuruluş Güvenlik Politikası", "organizationSecurityPolicyDescription": "Bu kuruluşun güvenlik gereksinimlerine erişmeden önce karşılanması gereken güvenlik gereksinimleri vardır.", "securityRequirements": "Güvenlik Gereksinimleri", "allRequirementsMet": "Tüm gereksinimler karşılandı", "completeRequirementsToContinue": "Bu kuruluşa erişmeye devam etmek için aşağıdaki gereksinimleri tamamlayın", "youCanNowAccessOrganization": "Artık bu kuruluşa erişebilirsiniz", "reauthenticationRequired": "Oturum Süresi", "reauthenticationDescription": "Bu kuruluş, {maxDays} günde bir oturum açmanızı gerektirir.", "reauthenticationDescriptionHours": "Bu kuruluş, {maxHours} saatte bir oturum açmanızı gerektirir.", "reauthenticateNow": "Tekrar Giriş Yap", "adminEnabled2FaOnYourAccount": "Yöneticiniz {email} için iki faktörlü kimlik doğrulamayı etkinleştirdi. Devam etmek için kurulum işlemini tamamlayın.", "securityKeyAdd": "Güvenlik Anahtarı Ekle", "securityKeyRegisterTitle": "Yeni Güvenlik Anahtarı Kaydet", "securityKeyRegisterDescription": "Güvenlik anahtarınızı bağlayın ve tanımlamak için bir ad girin", "securityKeyTwoFactorRequired": "İki Faktörlü Kimlik Doğrulama Gereklidir", "securityKeyTwoFactorDescription": "Güvenlik anahtarını kaydetmek için lütfen iki faktörlü kimlik doğrulama kodunuzu girin", "securityKeyTwoFactorRemoveDescription": "Güvenlik anahtarını kaldırmak için lütfen iki faktörlü kimlik doğrulama kodunuzu girin", "securityKeyTwoFactorCode": "İki Faktörlü Kod", "securityKeyRemoveTitle": "Güvenlik Anahtarını Kaldır", "securityKeyRemoveDescription": "Güvenlik anahtarını \"{name}\" kaldırmak için şifrenizi girin", "securityKeyNoKeysRegistered": "Kayıtlı güvenlik anahtarı yok", "securityKeyNoKeysDescription": "Hesabınızın güvenliğini artırmak için bir güvenlik anahtarı ekleyin", "createDomainRequired": "Alan adı gereklidir", "createDomainAddDnsRecords": "DNS Kayıtlarını Ekle", "createDomainAddDnsRecordsDescription": "Kurulumu tamamlamak için alan sağlayıcınıza şu DNS kayıtlarını ekleyin.", "createDomainNsRecords": "NS Kayıtları", "createDomainRecord": "Kayıt", "createDomainType": "Tür:", "createDomainName": "Ad:", "createDomainValue": "Değer:", "createDomainCnameRecords": "CNAME Kayıtları", "createDomainARecords": "A Kayıtları", "createDomainRecordNumber": "Kayıt {number}", "createDomainTxtRecords": "TXT Kayıtları", "createDomainSaveTheseRecords": "Bu Kayıtları Kaydet", "createDomainSaveTheseRecordsDescription": "Bu DNS kayıtlarını kaydettiğinizden emin olun çünkü tekrar görmeyeceksiniz.", "createDomainDnsPropagation": "DNS Yayılması", "createDomainDnsPropagationDescription": "DNS değişikliklerinin internet genelinde yayılması zaman alabilir. DNS sağlayıcınız ve TTL ayarlarına bağlı olarak bu birkaç dakika ile 48 saat arasında değişebilir.", "resourcePortRequired": "HTTP dışı kaynaklar için bağlantı noktası numarası gereklidir", "resourcePortNotAllowed": "HTTP kaynakları için bağlantı noktası numarası ayarlanmamalı", "billingPricingCalculatorLink": "Fiyat Hesaplayıcı", "billingYourPlan": "Planınız", "billingViewOrModifyPlan": "Mevcut planınızı görüntüleyin veya düzenleyin", "billingViewPlanDetails": "Plan Detaylarını Görüntüle", "billingUsageAndLimits": "Kullanım ve Sınırlar", "billingViewUsageAndLimits": "Planınızın limitlerini ve mevcut kullanım durumunu görüntüleyin", "billingCurrentUsage": "Mevcut Kullanım", "billingMaximumLimits": "Maksimum Sınırlar", "billingRemoteNodes": "Uzak Düğümler", "billingUnlimited": "Sınırsız", "billingPaidLicenseKeys": "Ücretli Lisans Anahtarları", "billingManageLicenseSubscription": "Kendi barındırdığınız ücretli lisans anahtarları için aboneliğinizi yönetin", "billingCurrentKeys": "Mevcut Anahtarlar", "billingModifyCurrentPlan": "Mevcut Planı Düzenle", "billingConfirmUpgrade": "Yükseltmeyi Onayla", "billingConfirmDowngrade": "Düşürmeyi Onayla", "billingConfirmUpgradeDescription": "Planınızı yükseltmek üzeresiniz. Yeni limitleri ve fiyatları aşağıda inceleyin.", "billingConfirmDowngradeDescription": "Planınızı düşürmek üzeresiniz. Yeni limitleri ve fiyatları aşağıda inceleyin.", "billingPlanIncludes": "Plan İçerikleri", "billingProcessing": "İşleniyor...", "billingConfirmUpgradeButton": "Yükseltmeyi Onayla", "billingConfirmDowngradeButton": "Düşürmeyi Onayla", "billingLimitViolationWarning": "Kullanım Yeni Plan Sınırlarını Aşıyor", "billingLimitViolationDescription": "Mevcut kullanımınız bu planın sınırlarını aşıyor. Düzeltmelerden sonra, yeni sınırlar içinde kalana kadar tüm işlemler devre dışı bırakılacak. Lütfen şu anda limitlerin üzerinde olan özellikleri inceleyin. İhlal edilen sınırlar:", "billingFeatureLossWarning": "Özellik Kullanılabilirlik Bildirimi", "billingFeatureLossDescription": "Plan düşürüldüğünde, yeni planda mevcut olmayan özellikler otomatik olarak devre dışı bırakılacaktır. Bazı ayarlar ve yapılar kaybolabilir. Hangi özelliklerin artık mevcut olmayacağını anlamak için fiyat tablosunu inceleyiniz.", "billingUsageExceedsLimit": "Mevcut kullanım ({current}) limitleri ({limit}) aşıyor", "billingPastDueTitle": "Ödeme Geçmiş", "billingPastDueDescription": "Ödemenizın vadesi geçti. Mevcut plan özelliklerinizi kullanmaya devam etmek için lütfen ödeme yöntemini güncelleyin. Sorun çözülmezse aboneliğiniz iptal edilecek ve ücretsiz seviyeye dönüleceksiniz.", "billingUnpaidTitle": "Ödenmemiş Abonelik", "billingUnpaidDescription": "Aboneliğiniz ödenmedi ve ücretsiz seviyeye geri döndünüz. Aboneliğinizi geri yüklemek için lütfen ödeme yöntemini güncelleyin.", "billingIncompleteTitle": "Eksik Ödeme", "billingIncompleteDescription": "Ödemeniz eksik. Aboneliğinizi etkinleştirmek için lütfen ödeme sürecini tamamlayın.", "billingIncompleteExpiredTitle": "Ödeme Süresi Doldu", "billingIncompleteExpiredDescription": "Ödemeniz hiç tamamlanmadı ve süresi doldu. Ücretsiz seviyeye geri döndünüz. Ücretli özelliklere erişimi yeniden sağlamak için lütfen yeniden abone olun.", "billingManageSubscription": "Aboneliğinizi Yönetin", "billingResolvePaymentIssue": "Yükseltmeden veya düşürmeden önce ödeme sorunuzu çözün", "signUpTerms": { "IAgreeToThe": "Kabul ediyorum", "termsOfService": "hizmet şartları", "and": "ve", "privacyPolicy": "gizlilik politikası." }, "signUpMarketing": { "keepMeInTheLoop": "Bana e-posta yoluyla haberler, güncellemeler ve yeni özellikler hakkında bilgi verin." }, "siteRequired": "Site gerekli.", "olmTunnel": "Olm Tüneli", "olmTunnelDescription": "Müşteri bağlantıları için Olm kullanın", "errorCreatingClient": "Müşteri oluşturulurken hata oluştu", "clientDefaultsNotFound": "Müşteri varsayılanları bulunamadı", "createClient": "Müşteri Oluştur", "createClientDescription": "Özel kaynaklara erişmek için yeni bir istemci oluşturun", "seeAllClients": "Tüm Müşterileri Gör", "clientInformation": "Müşteri Bilgileri", "clientNamePlaceholder": "Müşteri adı", "address": "Adres", "subnetPlaceholder": "Alt ağ", "addressDescription": "İstemcinin dahili adresi. Organizasyon alt ağı içinde olmalıdır.", "selectSites": "Siteleri seçin", "sitesDescription": "Müşteri seçilen sitelere bağlantı kuracaktır", "clientInstallOlm": "Olm Yükle", "clientInstallOlmDescription": "Sisteminizde Olm çalıştırın", "clientOlmCredentials": "Olm Kimlik Bilgileri", "clientOlmCredentialsDescription": "Bu, istemcinin sunucu ile kimlik doğrulaması yapacağı yöntemdir", "olmEndpoint": "Uç Nokta", "olmId": "Kimlik", "olmSecretKey": "Gizli", "clientCredentialsSave": "Kimlik Bilgilerinizi Kaydedin", "clientCredentialsSaveDescription": "Bunu yalnızca bir kez görebileceksiniz. Güvenli bir yere kopyaladığınızdan emin olun.", "generalSettingsDescription": "Bu müşteri için genel ayarları yapılandırın", "clientUpdated": "Müşteri güncellendi", "clientUpdatedDescription": "Müşteri güncellenmiştir.", "clientUpdateFailed": "Müşteri güncellenemedi", "clientUpdateError": "Müşteri güncellenirken bir hata oluştu.", "sitesFetchFailed": "Siteler alınamadı", "sitesFetchError": "Siteler alınırken bir hata oluştu.", "olmErrorFetchReleases": "Olm yayınları alınırken bir hata oluştu.", "olmErrorFetchLatest": "En son Olm yayını alınırken bir hata oluştu.", "enterCidrRange": "CIDR aralığını girin", "resourceEnableProxy": "Genel Proxy'i Etkinleştir", "resourceEnableProxyDescription": "Bu kaynağa genel proxy erişimini etkinleştirin. Bu sayede ağ dışından açık bir port üzerinden kaynağa bulut aracılığıyla erişim sağlanır. Traefik yapılandırması gereklidir.", "externalProxyEnabled": "Dış Proxy Etkinleştirildi", "addNewTarget": "Yeni Hedef Ekle", "targetsList": "Hedefler Listesi", "advancedMode": "Gelişmiş Mod", "advancedSettings": "Gelişmiş Ayarlar", "targetErrorDuplicateTargetFound": "Yinelenen hedef bulundu", "healthCheckHealthy": "Sağlıklı", "healthCheckUnhealthy": "Sağlıksız", "healthCheckUnknown": "Bilinmiyor", "healthCheck": "Sağlık Kontrolü", "configureHealthCheck": "Sağlık Kontrolünü Yapılandır", "configureHealthCheckDescription": "{hedef} için sağlık izleme kurun", "enableHealthChecks": "Sağlık Kontrollerini Etkinleştir", "enableHealthChecksDescription": "Bu hedefin sağlığını izleyin. Gerekirse hedef dışındaki bir son noktayı izleyebilirsiniz.", "healthScheme": "Yöntem", "healthSelectScheme": "Yöntem Seç", "healthCheckPortInvalid": "Sağlık Kontrolü portu 1 ile 65535 arasında olmalıdır", "healthCheckPath": "Yol", "healthHostname": "IP / Hostname", "healthPort": "Bağlantı Noktası", "healthCheckPathDescription": "Sağlık durumunu kontrol etmek için yol.", "healthyIntervalSeconds": "Sağlıklı Aralık (saniye)", "unhealthyIntervalSeconds": "Sağlıksız Aralık (saniye)", "IntervalSeconds": "Sağlıklı Aralık", "timeoutSeconds": "Zaman Aşımı (saniye)", "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 virgülle ayrılmış listesi.", "sshCreateHomeDir": "Ev Dizini Oluştur", "sshUnixGroups": "Unix Grupları", "sshUnixGroupsDescription": "Hedef konakta kullanıcıya eklenecek Unix gruplarının virgülle ayrılmış listesi.", "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.", "customHeaders": "Özel Başlıklar", "customHeadersDescription": "Başlıklar yeni satırla ayrılmış: Başlık-Adı: değer", "headersValidationError": "Başlıklar şu formatta olmalıdır: Başlık-Adı: değer.", "saveHealthCheck": "Sağlık Kontrolünü Kaydet", "healthCheckSaved": "Sağlık Kontrolü Kaydedildi", "healthCheckSavedDescription": "Sağlık kontrol yapılandırması başarıyla kaydedildi", "healthCheckError": "Sağlık Kontrol Hatası", "healthCheckErrorDescription": "Sağlık kontrol yapılandırması kaydedilirken bir hata oluştu", "healthCheckPathRequired": "Sağlık kontrol yolu gereklidir", "healthCheckMethodRequired": "HTTP yöntemi gereklidir", "healthCheckIntervalMin": "Kontrol aralığı en az 5 saniye olmalıdır", "healthCheckTimeoutMin": "Zaman aşımı en az 1 saniye olmalıdır", "healthCheckRetryMin": "Tekrar deneme girişimleri en az 1 olmalıdır", "httpMethod": "HTTP Yöntemi", "selectHttpMethod": "HTTP yöntemini seçin", "domainPickerSubdomainLabel": "Alt Alan Adı", "domainPickerBaseDomainLabel": "Temel Alan Adı", "domainPickerSearchDomains": "Alan adlarını ara...", "domainPickerNoDomainsFound": "Hiçbir alan adı bulunamadı", "domainPickerLoadingDomains": "Alan adları yükleniyor...", "domainPickerSelectBaseDomain": "Temel alan adını seçin...", "domainPickerNotAvailableForCname": "CNAME alan adları için kullanılabilir değil", "domainPickerEnterSubdomainOrLeaveBlank": "Alt alan adını girin veya temel alan adını kullanmak için boş bırakın.", "domainPickerEnterSubdomainToSearch": "Mevcut ücretsiz alan adları arasından aramak ve seçmek için bir alt alan adı girin.", "domainPickerFreeDomains": "Ücretsiz Alan Adları", "domainPickerSearchForAvailableDomains": "Mevcut alan adlarını ara", "domainPickerNotWorkSelfHosted": "Not: Ücretsiz sağlanan alan adları şu anda öz-host edilmiş örnekler için kullanılabilir değildir.", "resourceDomain": "Alan Adı", "resourceEditDomain": "Alan Adını Düzenle", "siteName": "Site Adı", "proxyPort": "Bağlantı Noktası", "resourcesTableProxyResources": "Herkese Açık", "resourcesTableClientResources": "Özel", "resourcesTableNoProxyResourcesFound": "Hiçbir proxy kaynağı bulunamadı.", "resourcesTableNoInternalResourcesFound": "Hiçbir dahili kaynak bulunamadı.", "resourcesTableDestination": "Hedef", "resourcesTableAlias": "Takma Ad", "resourcesTableAliasAddress": "Alias Adresi", "resourcesTableAliasAddressInfo": "Bu adres, kuruluşun yardımcı ağ alt bantının bir parçasıdır. Alias kayıtlarını çözümlemek için dahili DNS çözümlemesi kullanılır.", "resourcesTableClients": "İstemciler", "resourcesTableAndOnlyAccessibleInternally": "veyalnızca bir istemci ile bağlandığında dahili olarak erişilebilir.", "resourcesTableNoTargets": "Hedef yok", "resourcesTableHealthy": "Sağlıklı", "resourcesTableDegraded": "Düşük Performanslı", "resourcesTableOffline": "Çevrimdışı", "resourcesTableUnknown": "Bilinmiyor", "resourcesTableNotMonitored": "İzlenmiyor", "editInternalResourceDialogEditClientResource": "Özel Kaynak Düzenleyin", "editInternalResourceDialogUpdateResourceProperties": "{resourceName} için kaynak ayarlarını ve erişim kontrollerini güncelleyin", "editInternalResourceDialogResourceProperties": "Kaynak Özellikleri", "editInternalResourceDialogName": "Ad", "editInternalResourceDialogProtocol": "Protokol", "editInternalResourceDialogSitePort": "Site Bağlantı Noktası", "editInternalResourceDialogTargetConfiguration": "Hedef Yapılandırma", "editInternalResourceDialogCancel": "İptal", "editInternalResourceDialogSaveResource": "Kaynağı Kaydet", "editInternalResourceDialogSuccess": "Başarı", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "Dahili kaynak başarıyla güncellendi", "editInternalResourceDialogError": "Hata", "editInternalResourceDialogFailedToUpdateInternalResource": "Dahili kaynak güncellenemedi", "editInternalResourceDialogNameRequired": "Ad gerekli", "editInternalResourceDialogNameMaxLength": "Ad 255 karakterden kısa olmalıdır", "editInternalResourceDialogProxyPortMin": "Proxy bağlantı noktası en az 1 olmalıdır", "editInternalResourceDialogProxyPortMax": "Proxy bağlantı noktası 65536'dan küçük olmalıdır", "editInternalResourceDialogInvalidIPAddressFormat": "Geçersiz IP adresi formatı", "editInternalResourceDialogDestinationPortMin": "Hedef bağlantı noktası en az 1 olmalıdır", "editInternalResourceDialogDestinationPortMax": "Hedef bağlantı noktası 65536'dan küçük olmalıdır", "editInternalResourceDialogPortModeRequired": "Port modu için protokol, proxy portu ve hedef porta ihtiyaç vardır", "editInternalResourceDialogMode": "Mod", "editInternalResourceDialogModePort": "Bağlantı Noktası", "editInternalResourceDialogModeHost": "Ev Sahibi", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "Hedef", "editInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.", "editInternalResourceDialogDestinationIPDescription": "Kaynağın site ağındaki IP veya ana bilgisayar adresi.", "editInternalResourceDialogDestinationCidrDescription": "Site ağındaki kaynağın CIDR aralığı.", "editInternalResourceDialogAlias": "Takma Ad", "editInternalResourceDialogAliasDescription": "Bu kaynak için isteğe bağlı dahili DNS takma adı.", "createInternalResourceDialogNoSitesAvailable": "Site Bulunamadı", "createInternalResourceDialogNoSitesAvailableDescription": "Dahili kaynak oluşturmak için en az bir Newt sitesine ve alt ağa sahip olmalısınız.", "createInternalResourceDialogClose": "Kapat", "createInternalResourceDialogCreateClientResource": "Özel Kaynak Oluştur", "createInternalResourceDialogCreateClientResourceDescription": "Seçilen siteye bağlı istemcilere erişilebilir olacak yeni bir kaynak oluşturun", "createInternalResourceDialogResourceProperties": "Kaynak Özellikleri", "createInternalResourceDialogName": "Ad", "createInternalResourceDialogSite": "Site", "selectSite": "Site seç...", "noSitesFound": "Site bulunamadı.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "Site Bağlantı Noktası", "createInternalResourceDialogSitePortDescription": "İstemci ile bağlanıldığında site üzerindeki kaynağa erişmek için bu bağlantı noktasını kullanın.", "createInternalResourceDialogTargetConfiguration": "Hedef Yapılandırma", "createInternalResourceDialogDestinationIPDescription": "Kaynağın site ağındaki IP veya ana bilgisayar adresi.", "createInternalResourceDialogDestinationPortDescription": "Kaynağa erişilebilecek hedef IP üzerindeki bağlantı noktası.", "createInternalResourceDialogCancel": "İptal", "createInternalResourceDialogCreateResource": "Kaynak Oluştur", "createInternalResourceDialogSuccess": "Başarı", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "Dahili kaynak başarıyla oluşturuldu", "createInternalResourceDialogError": "Hata", "createInternalResourceDialogFailedToCreateInternalResource": "Dahili kaynak oluşturulamadı", "createInternalResourceDialogNameRequired": "Ad gerekli", "createInternalResourceDialogNameMaxLength": "Ad 255 karakterden kısa olmalıdır", "createInternalResourceDialogPleaseSelectSite": "Lütfen bir site seçin", "createInternalResourceDialogProxyPortMin": "Proxy bağlantı noktası en az 1 olmalıdır", "createInternalResourceDialogProxyPortMax": "Proxy bağlantı noktası 65536'dan küçük olmalıdır", "createInternalResourceDialogInvalidIPAddressFormat": "Geçersiz IP adresi formatı", "createInternalResourceDialogDestinationPortMin": "Hedef bağlantı noktası en az 1 olmalıdır", "createInternalResourceDialogDestinationPortMax": "Hedef bağlantı noktası 65536'dan küçük olmalıdır", "createInternalResourceDialogPortModeRequired": "Port modu için protokol, proxy portu ve hedef porta ihtiyaç vardır", "createInternalResourceDialogMode": "Mod", "createInternalResourceDialogModePort": "Bağlantı Noktası", "createInternalResourceDialogModeHost": "Ev Sahibi", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "Hedef", "createInternalResourceDialogDestinationHostDescription": "Site ağındaki kaynağın IP adresi veya ana bilgisayar adı.", "createInternalResourceDialogDestinationCidrDescription": "Site ağındaki kaynağın CIDR aralığı.", "createInternalResourceDialogAlias": "Takma Ad", "createInternalResourceDialogAliasDescription": "Bu kaynak için isteğe bağlı dahili DNS takma adı.", "siteConfiguration": "Yapılandırma", "siteAcceptClientConnections": "İstemci Bağlantılarını Kabul Et", "siteAcceptClientConnectionsDescription": "Kullanıcı cihazları ve istemcilerin bu sitedeki kaynaklara erişmesine izin verin. Bu daha sonra değiştirilebilir.", "siteAddress": "Site Adresi (Gelişmiş)", "siteAddressDescription": "Site için dahili adres. Organizasyon alt ağı içinde olmalıdır.", "siteNameDescription": "Sonradan değiştirilebilecek sitenin görünen adı.", "autoLoginExternalIdp": "Harici IDP ile Otomatik Giriş", "autoLoginExternalIdpDescription": "Kullanıcıyı kimlik doğrulama için hemen harici kimlik sağlayıcısına yönlendirin.", "selectIdp": "IDP Seç", "selectIdpPlaceholder": "IDP seçin...", "selectIdpRequired": "Otomatik giriş etkinleştirildiğinde lütfen bir IDP seçin.", "autoLoginTitle": "Yönlendiriliyor", "autoLoginDescription": "Kimlik doğrulama için harici kimlik sağlayıcıya yönlendiriliyorsunuz.", "autoLoginProcessing": "Kimlik doğrulama hazırlanıyor...", "autoLoginRedirecting": "Girişe yönlendiriliyorsunuz...", "autoLoginError": "Otomatik Giriş Hatası", "autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.", "autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı.", "remoteExitNodeManageRemoteExitNodes": "Uzak Düğümler", "remoteExitNodeDescription": "Kendi uzaktan yönetilen ileti ve ara sunucu düğümlerinizi barındırın", "remoteExitNodes": "Düğümler", "searchRemoteExitNodes": "Düğüm ara...", "remoteExitNodeAdd": "Düğüm Ekle", "remoteExitNodeErrorDelete": "Düğüm silinirken hata oluştu", "remoteExitNodeQuestionRemove": "Düğümü organizasyondan kaldırmak istediğinizden emin misiniz?", "remoteExitNodeMessageRemove": "Kaldırıldığında, düğüm artık erişilebilir olmayacaktır.", "remoteExitNodeConfirmDelete": "Düğüm Silmeyi Onayla", "remoteExitNodeDelete": "Düğümü Sil", "sidebarRemoteExitNodes": "Uzak Düğümler", "remoteExitNodeId": "Kimlik", "remoteExitNodeSecretKey": "Gizli", "remoteExitNodeCreate": { "title": "Uzak Düğüm Oluştur", "description": "Yeni bir kendine misafir uzaktan ileti ve ara sunucu düğümü oluşturun", "viewAllButton": "Tüm Düğümleri Gör", "strategy": { "title": "Oluşturma Stratejisi", "description": "Uzak düğümü nasıl oluşturmak istediğinizi seçin", "adopt": { "title": "Düğüm Benimse", "description": "Zaten düğüm için kimlik bilgilerine sahipseniz bunu seçin." }, "generate": { "title": "Anahtarları Oluştur", "description": "Düğüm için yeni anahtarlar oluşturmak istiyorsanız bunu seçin." } }, "adopt": { "title": "Mevcut Düğümü Benimse", "description": "Adayacağınız mevcut düğümün kimlik bilgilerini girin", "nodeIdLabel": "Düğüm ID", "nodeIdDescription": "Adayacağınız mevcut düğümün ID'si", "secretLabel": "Gizli", "secretDescription": "Mevcut düğümün gizli anahtarı", "submitButton": "Düğümü Benimse" }, "generate": { "title": "Oluşturulan Kimlik Bilgileri", "description": "Düğümünüzü yapılandırmak için bu oluşturulan kimlik bilgilerini kullanın", "nodeIdTitle": "Düğüm ID", "secretTitle": "Gizli", "saveCredentialsTitle": "Kimlik Bilgilerini Yapılandırmaya Ekle", "saveCredentialsDescription": "Bağlantıyı tamamlamak için bu kimlik bilgilerini öz-host Pangolin düğüm yapılandırma dosyanıza ekleyin.", "submitButton": "Düğüm Oluştur" }, "validation": { "adoptRequired": "Mevcut bir düğümü benimserken Düğüm ID ve Gizli anahtar gereklidir" }, "errors": { "loadDefaultsFailed": "Varsayılanlar yüklenemedi", "defaultsNotLoaded": "Varsayılanlar yüklenmedi", "createFailed": "Düğüm oluşturulamadı" }, "success": { "created": "Düğüm başarıyla oluşturuldu" } }, "remoteExitNodeSelection": "Düğüm Seçimi", "remoteExitNodeSelectionDescription": "Yerel site için trafiği yönlendirecek düğümü seçin", "remoteExitNodeRequired": "Yerel siteler için bir düğüm seçilmelidir", "noRemoteExitNodesAvailable": "Düğüm Bulunamadı", "noRemoteExitNodesAvailableDescription": "Bu organizasyon için düğüm mevcut değil. Yerel siteleri kullanmak için önce bir düğüm oluşturun.", "exitNode": "Çıkış Düğümü", "country": "Ülke", "rulesMatchCountry": "Şu anda kaynak IP'ye dayanarak", "managedSelfHosted": { "title": "Yönetilen Self-Hosted", "description": "Daha güvenilir ve düşük bakım gerektiren, ekstra özelliklere sahip kendi kendine barındırabileceğiniz Pangolin sunucusu", "introTitle": "Yönetilen Kendi Kendine Barındırılan Pangolin", "introDescription": "Bu, basitlik ve ekstra güvenilirlik arayan, ancak verilerini gizli tutmak ve kendi sunucularında barındırmak isteyen kişiler için tasarlanmış bir dağıtım seçeneğidir.", "introDetail": "Bu seçenekle, kendi Pangolin düğümünüzü çalıştırmaya devam edersiniz — tünelleriniz, SSL bitişiniz ve trafiğiniz tamamen sunucunuzda kalır. Fark, yönetim ve izlemeyi bulut panomuz üzerinden gerçekleştiririz, bu da bir dizi avantaj sağlar:", "benefitSimplerOperations": { "title": "Daha basit işlemler", "description": "Kendi e-posta sunucunuzu çalıştırmanıza veya karmaşık uyarılar kurmanıza gerek yok. Sağlık kontrolleri ve kesinti uyarılarını kutudan çıktığı gibi alırsınız." }, "benefitAutomaticUpdates": { "title": "Otomatik güncellemeler", "description": "Bulut panosu hızla gelişir, böylece her seferinde yeni konteynerler manuel olarak çekmeden yeni özellikler ve hata düzeltmeleri alırsınız." }, "benefitLessMaintenance": { "title": "Daha az bakım", "description": "Veritabanı geçişleri, yedeklemeler veya ekstra altyapı yönetimi yok. Biz bunu bulutta hallederiz." }, "benefitCloudFailover": { "title": "Bulut yedekleme", "description": "Düğümünüz kapandığında, tünelleriniz geçici olarak bulut bağlantı noktalarımıza geçebilir, böylece tekrar çevrimiçi hale getirene kadar tünelleriniz kesintiye uğramaz." }, "benefitHighAvailability": { "title": "Yüksek kullanılabilirlik (Bağlantı Noktaları)", "description": "Yedeklilik ve daha iyi performans için hesabınıza birden fazla düğüm bağlayabilirsiniz." }, "benefitFutureEnhancements": { "title": "Gelecek iyileştirmeler", "description": "Dağıtımınızı daha sağlam hale getirmek amacıyla daha fazla analiz, uyarı ve yönetim aracı eklemeyi planlıyoruz." }, "docsAlert": { "text": "Yönetilen Kendi Kendine Barındırılan seçeneği hakkında daha fazla bilgi edinin", "documentation": "dokümantasyon" }, "convertButton": "Bu Düğümü Yönetilen Kendi Kendine Barındırma Dönüştürün" }, "internationaldomaindetected": "Uluslararası Alan Adı Tespit Edildi", "willbestoredas": "Şu şekilde depolanacak:", "roleMappingDescription": "Otomatik Sağlama etkinleştirildiğinde kullanıcıların oturum açarken rollerin nasıl atandığını belirleyin.", "selectRole": "Bir Rol Seçin", "roleMappingExpression": "İfade", "selectRolePlaceholder": "Bir rol seçin", "selectRoleDescription": "Bu kimlik sağlayıcısından tüm kullanıcılara atanacak bir rol seçin", "roleMappingExpressionDescription": "Rol bilgilerini ID tokeninden çıkarmak için bir JMESPath ifadesi girin", "idpTenantIdRequired": "Kiracı Kimliği gereklidir", "invalidValue": "Geçersiz değer", "idpTypeLabel": "Kimlik Sağlayıcı Türü", "roleMappingExpressionPlaceholder": "örn., contains(gruplar, 'yönetici') && 'Yönetici' || 'Üye'", "idpGoogleConfiguration": "Google Yapılandırması", "idpGoogleConfigurationDescription": "Google OAuth2 kimlik bilgilerinizi yapılandırın", "idpGoogleClientIdDescription": "Google OAuth2 İstemci Kimliğiniz", "idpGoogleClientSecretDescription": "Google OAuth2 İstemci Sırrınız", "idpAzureConfiguration": "Azure Entra ID Yapılandırması", "idpAzureConfigurationDescription": "Azure Entra ID OAuth2 kimlik bilgilerinizi yapılandırın", "idpTenantId": "Kiracı Kimliği", "idpTenantIdPlaceholder": "kiracı Kimliği", "idpAzureTenantIdDescription": "Azure kiracı kimliğiniz (Azure Active Directory genel bakışında bulunur)", "idpAzureClientIdDescription": "Azure Uygulama Kaydı İstemci Kimliğiniz", "idpAzureClientSecretDescription": "Azure Uygulama Kaydı İstemci Sırrınız", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Google Yapılandırması", "idpAzureConfigurationTitle": "Azure Entra ID Yapılandırması", "idpTenantIdLabel": "Kiracı Kimliği", "idpAzureClientIdDescription2": "Azure Uygulama Kaydı İstemci Kimliğiniz", "idpAzureClientSecretDescription2": "Azure Uygulama Kaydı İstemci Sırrınız", "idpGoogleDescription": "Google OAuth2/OIDC sağlayıcısı", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı", "subnet": "Alt ağ", "subnetDescription": "Bu organizasyonun ağ yapılandırması için alt ağ.", "customDomain": "Özel Alan", "authPage": "Kimlik Sayfaları", "authPageDescription": "Kuruluşun kimlik doğrulama sayfaları için özel bir alan belirleyin", "authPageDomain": "Yetkilendirme Sayfası Alanı", "authPageBranding": "Özel Marka", "authPageBrandingDescription": "Bu kuruluşa ait kimlik doğrulama sayfalarında görünecek markayı yapılandırın", "authPageBrandingUpdated": "Kimlik doğrulama sayfası marka güncellemesi başarıyla tamamlandı", "authPageBrandingRemoved": "Kimlik doğrulama sayfası marka kaldırıldı", "authPageBrandingRemoveTitle": "Kimlik Doğrulama Sayfası Markası Kaldır", "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)", "brandingOrgTitle": "Kuruluş Kimlik Sayfası için Başlık", "brandingOrgDescription": "{orgName} kuruluş adı ile değiştirilecek", "brandingOrgSubtitle": "Kuruluş Kimlik Sayfası için Alt Başlık", "brandingResourceTitle": "Kaynak Yetkilendirme Sayfası için Başlık", "brandingResourceSubtitle": "Kaynak Yetkilendirme Sayfası için Alt Başlık", "brandingResourceDescription": "{resourceName} organizasyon adıyla değiştirilecek", "saveAuthPageDomain": "Alan Adını Kaydet", "saveAuthPageBranding": "Markayı Kaydet", "removeAuthPageBranding": "Markayı Kaldır", "noDomainSet": "Alan belirlenmedi", "changeDomain": "Alanı Değiştir", "selectDomain": "Alan Seçin", "restartCertificate": "Sertifikayı Yenile", "editAuthPageDomain": "Yetkilendirme Sayfası Alanını Düzenle", "setAuthPageDomain": "Yetkilendirme Sayfası Alanını Ayarla", "failedToFetchCertificate": "Sertifika getirilemedi", "failedToRestartCertificate": "Sertifika yeniden başlatılamadı", "addDomainToEnableCustomAuthPages": "Kullanıcılar, bu alanı kullanarak kuruluşun giriş sayfasına erişebilir ve kaynak kimlik doğrulamasını tamamlayabilir.", "selectDomainForOrgAuthPage": "Kuruluşun kimlik doğrulama sayfası için bir alan seçin", "domainPickerProvidedDomain": "Sağlanan Alan Adı", "domainPickerFreeProvidedDomain": "Ücretsiz Sağlanan Alan Adı", "domainPickerVerified": "Doğrulandı", "domainPickerUnverified": "Doğrulanmadı", "domainPickerInvalidSubdomainStructure": "Bu alt alan adı geçersiz karakterler veya yapı içeriyor. Kaydettiğinizde otomatik olarak temizlenecektir.", "domainPickerError": "Hata", "domainPickerErrorLoadDomains": "Organizasyon alan adları yüklenemedi", "domainPickerErrorCheckAvailability": "Alan adı kullanılabilirliği kontrol edilemedi", "domainPickerInvalidSubdomain": "Geçersiz alt alan adı", "domainPickerInvalidSubdomainRemoved": "Girdi \"{sub}\" geçersiz olduğu için kaldırıldı.", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" {domain} için geçerli yapılamadı.", "domainPickerSubdomainSanitized": "Alt alan adı temizlendi", "domainPickerSubdomainCorrected": "\"{sub}\" \"{sanitized}\" olarak düzeltildi", "orgAuthSignInTitle": "Kuruluş Giriş", "orgAuthChooseIdpDescription": "Devam etmek için kimlik sağlayıcınızı seçin", "orgAuthNoIdpConfigured": "Bu kuruluşta yapılandırılmış kimlik sağlayıcı yok. Bunun yerine Pangolin kimliğinizle giriş yapabilirsiniz.", "orgAuthSignInWithPangolin": "Pangolin ile Giriş Yap", "orgAuthSignInToOrg": "Bir kuruluşa giriş yapın", "orgAuthSelectOrgTitle": "Kuruluş Giriş", "orgAuthSelectOrgDescription": "Devam etmek için kuruluş kimliğinizi girin", "orgAuthOrgIdPlaceholder": "kuruluşunuz", "orgAuthOrgIdHelp": "Kuruluşunuzun benzersiz tanımlayıcısını girin", "orgAuthSelectOrgHelp": "Kuruluş kimliğinizi girdikten sonra, SSO veya kuruluş kimlik bilgilerinizi kullanabileceğiniz kuruluş giriş sayfanıza yönlendirileceksiniz.", "orgAuthRememberOrgId": "Bu kuruluş kimliğini hatırla", "orgAuthBackToSignIn": "Standart girişe geri dön", "orgAuthNoAccount": "Hesabınız yok mu?", "subscriptionRequiredToUse": "Bu özelliği kullanmak için abonelik gerekmektedir.", "mustUpgradeToUse": "Bu özelliği kullanmak için aboneliğinizi yükseltmelisiniz.", "subscriptionRequiredTierToUse": "Bu özellik {tier} veya daha üstünü gerektirir.", "upgradeToTierToUse": "Bu özelliği kullanmak için {tier} veya daha üst bir seviyeye yükseltin.", "subscriptionTierTier1": "Ana Sayfa", "subscriptionTierTier2": "Takım", "subscriptionTierTier3": "İşletme", "subscriptionTierEnterprise": "Kurumsal", "idpDisabled": "Kimlik sağlayıcılar devre dışı bırakılmıştır.", "orgAuthPageDisabled": "Kuruluş kimlik doğrulama sayfası devre dışı bırakılmıştır.", "domainRestartedDescription": "Alan doğrulaması başarıyla yeniden başlatıldı", "resourceAddEntrypointsEditFile": "Dosyayı düzenle: config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "Dosyayı düzenle: docker-compose.yml", "emailVerificationRequired": "E-posta doğrulaması gereklidir. Bu adımı tamamlamak için lütfen tekrar {dashboardUrl}/auth/login üzerinden oturum açın. Sonra buraya geri dönün.", "twoFactorSetupRequired": "İki faktörlü kimlik doğrulama ayarı gereklidir. Bu adımı tamamlamak için lütfen tekrar {dashboardUrl}/auth/login üzerinden oturum açın. Sonra buraya geri dönün.", "additionalSecurityRequired": "Ek Güvenlik Gereklidir", "organizationRequiresAdditionalSteps": "Bu kuruluş, kaynaklara erişmeden önce ek güvenlik adımları gerektirir.", "completeTheseSteps": "Bu adımları tamamlayın", "enableTwoFactorAuthentication": "İki faktörlü kimlik doğrulamayı etkinleştir", "completeSecuritySteps": "Güvenlik Adımlarını Tamamla", "securitySettings": "Güvenlik Ayarları", "dangerSection": "Tehlike Alanı", "dangerSectionDescription": "Bu organizasyonla ilişkili tüm verileri kalıcı olarak silin", "securitySettingsDescription": "Kuruluşunuz için güvenlik politikalarını yapılandırın", "requireTwoFactorForAllUsers": "Tüm Kullanıcılar için İki Faktörlü Kimlik Doğrulama Gerektir", "requireTwoFactorDescription": "Etkinleştirildiğinde, bu kuruluştaki tüm dahili kullanıcıların, kuruluşa erişmek için iki faktörlü kimlik doğrulama etkinleştirilmiş olmalıdır.", "requireTwoFactorDisabledDescription": "Bu özellik, geçerli bir lisans (Kurumsal) veya aktif bir abonelik (SaaS) gerektirir", "requireTwoFactorCannotEnableDescription": "Tüm kullanıcılar için etkinleştirilmeden önce hesabınızda iki faktörlü kimlik doğrulamayı etkinleştirmeniz gerekir", "maxSessionLength": "Maksimum Oturum Süresi", "maxSessionLengthDescription": "Kullanıcı oturumları için maksimum süreyi ayarlayın. Bu süre sonra kullanıcıların tekrar kimlik doğrulaması gerekecektir.", "maxSessionLengthDisabledDescription": "Bu özellik, geçerli bir lisans (Kurumsal) veya aktif bir abonelik (SaaS) gerektirir", "selectSessionLength": "Oturum süresini seçin", "unenforced": "Zorunlu Değil", "1Hour": "1 saat", "3Hours": "3 saat", "6Hours": "6 saat", "12Hours": "12 saat", "1DaySession": "1 gün", "3Days": "3 gün", "7Days": "7 gün", "14Days": "14 gün", "30DaysSession": "30 gün", "90DaysSession": "90 gün", "180DaysSession": "180 gün", "passwordExpiryDays": "Şifre Sona Ermesi", "editPasswordExpiryDescription": "Kullanıcıların parolalarını değiştirmeleri gereken gün sayısını ayarlayın.", "selectPasswordExpiry": "Şifre sona ermesini seçin", "30Days": "30 gün", "1Day": "1 gün", "60Days": "60 gün", "90Days": "90 gün", "180Days": "180 gün", "1Year": "1 yıl", "subscriptionBadge": "Abonelik Gerekiyor", "securityPolicyChangeWarning": "Güvenlik Politikası Değişiklik Uyarısı", "securityPolicyChangeDescription": "Güvenlik politikası ayarlarını değiştirmek üzeresiniz. Değişiklikleri kaydettikten sonra, bu politika güncellemelerine uyum sağlamak amacıyla tekrar kimlik doğrulamanız gerekebilir. Uyum sağlamayan tüm kullanıcıların da tekrar kimlik doğrulaması gerekecektir.", "securityPolicyChangeConfirmMessage": "Onaylıyorum", "securityPolicyChangeWarningText": "Bu, organizasyondaki tüm kullanıcıları etkileyecektir", "authPageErrorUpdateMessage": "Kimlik doğrulama sayfası ayarları güncellenirken bir hata oluştu.", "authPageErrorUpdate": "Kimlik doğrulama sayfası güncellenemedi", "authPageDomainUpdated": "Kimlik doğrulama sayfası alanı başarıyla güncellendi", "healthCheckNotAvailable": "Yerel", "rewritePath": "Yolu Yeniden Yaz", "rewritePathDescription": "Seçenek olarak hedefe iletmeden önce yolu yeniden yazın.", "continueToApplication": "Uygulamaya Devam Et", "checkingInvite": "Davet Kontrol Ediliyor", "setResourceHeaderAuth": "setResourceHeaderAuth", "resourceHeaderAuthRemove": "Başlık Kimlik Doğrulama Kaldır", "resourceHeaderAuthRemoveDescription": "Başlık kimlik doğrulama başarıyla kaldırıldı.", "resourceErrorHeaderAuthRemove": "Başlık Kimlik Doğrulama kaldırılamadı", "resourceErrorHeaderAuthRemoveDescription": "Kaynak için başlık kimlik doğrulaması kaldırılamadı.", "resourceHeaderAuthProtectionEnabled": "Başlık Doğrulaması Etkin", "resourceHeaderAuthProtectionDisabled": "Başlık Doğrulaması Devre Dışı", "headerAuthRemove": "Başlık Doğrulaması Kaldır", "headerAuthAdd": "Başlık Doğrulaması Ekle", "resourceErrorHeaderAuthSetup": "Başlık Kimlik Doğrulama ayarlanamadı", "resourceErrorHeaderAuthSetupDescription": "Kaynak için başlık kimlik doğrulaması ayarlanamadı.", "resourceHeaderAuthSetup": "Başlık Kimlik Doğrulama başarıyla ayarlandı", "resourceHeaderAuthSetupDescription": "Başlık kimlik doğrulaması başarıyla ayarlandı.", "resourceHeaderAuthSetupTitle": "Başlık Kimlik Doğrulama Ayarla", "resourceHeaderAuthSetupTitleDescription": "Bu kaynağı HTTP Başlık Kimlik Doğrulaması ile korumak için temel kimlik bilgilerini (kullanıcı adı ve şifre) ayarlayın. Kaynağa erişim için https://username:password@resource.example.com formatını kullanın.", "resourceHeaderAuthSubmit": "Başlık Kimlik Doğrulama Ayarla", "actionSetResourceHeaderAuth": "Başlık Kimlik Doğrulama Ayarla", "enterpriseEdition": "Kurumsal Sürüm", "unlicensed": "Lisansız", "beta": "Beta", "manageUserDevices": "Kullanıcı Cihazları", "manageUserDevicesDescription": "Kullanıcıların kaynaklara özel olarak bağlanmak için kullandığı cihazları görüntüleyin ve yönetin", "downloadClientBannerTitle": "Pangolin İstemcisini İndir", "downloadClientBannerDescription": "Sisteminize Pangolin istemcisini indirerek Pangolin ağına bağlanın ve kaynaklara özel olarak erişim sağlayın.", "manageMachineClients": "Makine İstemcilerini Yönetin", "manageMachineClientsDescription": "Sunucuların ve sistemlerin kaynaklara özel olarak bağlanmak için kullandığı istemcileri oluşturun ve yönetin", "machineClientsBannerTitle": "Sunucular ve Otomatik Sistemler", "machineClientsBannerDescription": "Makine müşterileri, belirli bir kullanıcı ile ilişkilendirilmemiş sunucular ve otomatik sistemler içindir. Kimlik ve şifreyle doğrulama yaparlar ve Pangolin CLI, Olm CLI veya Olm'yi bir konteyner olarak çalıştırabilirler.", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Olm Konteyner", "clientsTableUserClients": "Kullanıcı", "clientsTableMachineClients": "Makine", "licenseTableValidUntil": "Geçerli İki Tarih Kadar", "saasLicenseKeysSettingsTitle": "Kurumsal Lisanslar", "saasLicenseKeysSettingsDescription": "Kendi barındırdığınız Pangolin örnekleri için kurumsal lisans anahtarları oluşturun ve yönetin.", "sidebarEnterpriseLicenses": "Lisanslar", "generateLicenseKey": "Lisans Anahtarı Oluştur", "generateLicenseKeyForm": { "validation": { "emailRequired": "Lütfen geçerli bir e-posta adresi girin.", "useCaseTypeRequired": "Lütfen bir kullanım alanı türü seçin.", "firstNameRequired": "İsim gereklidir.", "lastNameRequired": "Soyisim gereklidir.", "primaryUseRequired": "Lütfen öncelikli kullanımınızı açıklayın.", "jobTitleRequiredBusiness": "İş kullanımı için iş unvanı gereklidir.", "industryRequiredBusiness": "İş kullanımı için sektör gereklidir.", "stateProvinceRegionRequired": "Eyalet/İl/Bölge gereklidir.", "postalZipCodeRequired": "Posta/ZIP kodu gereklidir.", "companyNameRequiredBusiness": "İş kullanımı için şirket ismi gereklidir.", "countryOfResidenceRequiredBusiness": "İş kullanımı için ikamet edilen ülke gereklidir.", "countryRequiredPersonal": "Kişisel kullanım için ülke gereklidir.", "agreeToTermsRequired": "Şartları kabul etmelisiniz.", "complianceConfirmationRequired": "Fossorial Ticari Lisans ile uyumluluğu doğrulamalısınız." }, "useCaseOptions": { "personal": { "title": "Kişisel Kullanım", "description": "Bireysel, ticari olmayan kullanım için öğrenme, kişisel projeler veya denemeler gibi." }, "business": { "title": "İş Kullanımı", "description": "Kuruluşlar, şirketler veya ticari veya gelir getirici faaliyetler bünyesinde kullanım için." } }, "steps": { "emailLicenseType": { "title": "E-posta ve Lisans Türü", "description": "E-posta adresinizi girin ve lisans türünüzü seçin." }, "personalInformation": { "title": "Kişisel Bilgiler", "description": "Kendinizden bahsedin." }, "contactInformation": { "title": "İletişim Bilgileri", "description": "İletişim detaylarınız." }, "termsGenerate": { "title": "Şartlar ve Lisans Üret", "description": "Lisansınızı oluşturmak için şartları inceleyin ve kabul edin." } }, "alerts": { "commercialUseDisclosure": { "title": "Kullanım Açıklaması", "description": "Kullanım amacınızı doğru bir şekilde yansıtan lisans seviyesini seçin. Kişisel Lisans, yazılımın bireysel, ticari olmayan veya yıllık geliri 100,000 ABD Dolarının altında olan küçük ölçekli ticari faaliyetlerde ücretsiz kullanılmasına izin verir. Bu sınırların ötesinde kullanım — bir işletme, organizasyon veya diğer gelir getirici ortamlarda kullanım dahil olmak üzere — geçerli bir Kurumsal Lisans ve ilgili lisans ücretinin ödenmesini gerektirir. Tüm kullanıcılar, ister Kişisel ister Kurumsal, Fossorial Ticari Lisans Şartlarına uymalıdır." }, "trialPeriodInformation": { "title": "Deneme Süresi Bilgileri", "description": "Bu Lisans Anahtarı, Kurumsal özellikleri 7 günlük bir değerlendirme süresi için etkinleştirir. Değerlendirme süresi bittikten sonra, Ücretli Özelliklere devam eden erişim, geçerli bir Kişisel veya Kurumsal Lisans altında etkinleşmeyi gerektirir. Kurumsal lisanslama için sales@pangolin.net ile iletişime geçin." } }, "form": { "useCaseQuestion": "Pangolin'i kişisel veya iş kullanımı için mi kullanıyorsunuz?", "firstName": "İsim", "lastName": "Soyisim", "jobTitle": "İş Unvanı", "primaryUseQuestion": "Pangolin'i öncelikli olarak ne amacıyla kullanmayı planlıyorsunuz?", "industryQuestion": "Hangi sektördesiniz?", "prospectiveUsersQuestion": "Kaç potansiyel kullanıcı öngörüyorsunuz?", "prospectiveSitesQuestion": "Kaç potansiyel site (tünel) öngörüyorsunuz?", "companyName": "Şirket İsmi", "countryOfResidence": "İkamet edilen ülke", "stateProvinceRegion": "Eyalet / İl / Bölge", "postalZipCode": "Posta / ZIP Kodu", "companyWebsite": "Şirket web sitesi", "companyPhoneNumber": "Şirket telefon numarası", "country": "Ülke", "phoneNumberOptional": "Telefon numarası (isteğe bağlı)", "complianceConfirmation": "Sağladığım bilgilerin doğru olduğunu ve Fossorial Ticari Lisans ile uyumlu olduğumu teyit ederim. Yanlış bilgi raporlamak veya ürün kullanımını yanlış tanımlamak lisans ihlalidir ve anahtarınızın iptal edilmesine neden olabilir." }, "buttons": { "close": "Kapat", "previous": "Önceki", "next": "Sonraki", "generateLicenseKey": "Lisans Anahtarı Oluştur" }, "toasts": { "success": { "title": "Lisans anahtarı başarıyla oluşturuldu", "description": "Lisans anahtarınız oluşturuldu ve kullanıma hazır." }, "error": { "title": "Lisans anahtarı oluşturulamadı", "description": "Lisans anahtarı oluşturulurken bir hata oluştu." } } }, "newPricingLicenseForm": { "title": "Bir lisans alın", "description": "Bir plan seçin ve Pangolin'i nasıl kullanmayı planladığınızı anlatın.", "chooseTier": "Planınızı seçin", "viewPricingLink": "Fiyatları, özellikleri ve limitleri görüntüleyin", "tiers": { "starter": { "title": "Başlangıç", "description": "Kurumsal özellikler, 25 kullanıcı, 25 site ve topluluk desteği." }, "scale": { "title": "Ölçek", "description": "Kurumsal özellikler, 50 kullanıcı, 50 site ve öncelikli destek." } }, "personalUseOnly": "Yalnızca kişisel kullanım (ücretsiz lisans — ödeme yapılmaz)", "buttons": { "continueToCheckout": "Ödemeye Devam Et" }, "toasts": { "checkoutError": { "title": "Ödeme Hatası", "description": "Ödeme işlemi başlatılamadı. Lütfen tekrar deneyin." } } }, "priority": "Öncelik", "priorityDescription": "Daha yüksek öncelikli rotalar önce değerlendirilir. Öncelik = 100, otomatik sıralama anlamına gelir (sistem karar verir). Manuel öncelik uygulamak için başka bir numara kullanın.", "instanceName": "Örnek İsmi", "pathMatchModalTitle": "Yol Eşleşmesini Yapılandır", "pathMatchModalDescription": "Gelen isteklerin yolu temel alarak nasıl eşleştirilmesi gerektiğini ayarlayın.", "pathMatchType": "Eşleşme Türü", "pathMatchPrefix": "Önek", "pathMatchExact": "Tam", "pathMatchRegex": "Regex", "pathMatchValue": "Yol Değeri", "clear": "Temizle", "saveChanges": "Değişiklikleri Kaydet", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/path", "pathMatchPrefixHelp": "Örnek: /api, /api/users vb.'ni eşleştirir.", "pathMatchExactHelp": "Örnek: /api yalnızca /api'yi eşleştirir", "pathMatchRegexHelp": "Örnek: ^/api/.* her şeyi eşleştirir /api/anything", "pathRewriteModalTitle": "Yolu Yeniden Yazmayı Yapılandır", "pathRewriteModalDescription": "Hedefe iletilmeden önce eşleşen yolu dönüştürün.", "pathRewriteType": "Yeniden Yazma Türü", "pathRewritePrefixOption": "Önek - Ön ek değiştirme", "pathRewriteExactOption": "Tam - Tüm yolu değiştir", "pathRewriteRegexOption": "Regex - Desen değiştirme", "pathRewriteStripPrefixOption": "Ön Ek Kaldır - Ön eki sil", "pathRewriteValue": "Yeniden Yazma Değeri", "pathRewriteRegexPlaceholder": "/new/$1", "pathRewriteDefaultPlaceholder": "/new-path", "pathRewritePrefixHelp": "Eşleşen öneki bu değerle değiştir", "pathRewriteExactHelp": "Yol tam olarak eşleştiğinde, yolun tamamını bu değerle değiştir", "pathRewriteRegexHelp": "Değiştirme için $1, $2 gibi yakalama gruplarını kullan", "pathRewriteStripPrefixHelp": "Öneki silmek veya yeni bir ön ek sağlamak için boş bırakın", "pathRewritePrefix": "Önek", "pathRewriteExact": "Tam", "pathRewriteRegex": "Regex", "pathRewriteStrip": "Sil", "pathRewriteStripLabel": "sil", "sidebarEnableEnterpriseLicense": "Kurumsal Lisans Etkinleştir", "cannotbeUndone": "Bu geri alınamaz.", "toConfirm": "onaylamak için.", "deleteClientQuestion": "Müşteriyi siteden ve organizasyondan kaldırmak istediğinizden emin misiniz?", "clientMessageRemove": "Kaldırıldıktan sonra müşteri siteye bağlanamayacaktır.", "sidebarLogs": "Kayıtlar", "request": "İstek", "requests": "İstekler", "logs": "Günlükler", "logsSettingsDescription": "Bu kuruluştan toplanan günlükleri izleyin", "searchLogs": "Günlüklerde ara...", "action": "Eylem", "actor": "Aktör", "timestamp": "Zaman damgası", "accessLogs": "Erişim Günlükleri", "exportCsv": "CSV Dışa Aktar", "exportError": "CSV dışa aktarılırken bilinmeyen hata", "exportCsvTooltip": "Zaman Aralığında", "actorId": "Aktör Kimliği", "allowedByRule": "Kurallara Göre İzin Verildi", "allowedNoAuth": "Kimlik Doğrulama Yok İzin Verildi", "validAccessToken": "Geçerli Erişim Jetonu", "validHeaderAuth": "Geçerli Başlık Doğrulama", "validPincode": "Geçerli Pincode", "validPassword": "Geçerli Şifre", "validEmail": "Geçerli E-posta", "validSSO": "Geçerli SSO", "resourceBlocked": "Kaynak Engellendi", "droppedByRule": "Kurallara Göre Çıkartıldı", "noSessions": "Oturum Yok", "temporaryRequestToken": "Geçici İstek Jetonu", "noMoreAuthMethods": "Daha Fazla Kimlik Doğrulama Yöntemi Yok", "ip": "IP", "reason": "Sebep", "requestLogs": "İstek Günlükleri", "requestAnalytics": "İstek Analizi", "host": "Sunucu", "location": "Konum", "actionLogs": "Eylem Günlükleri", "sidebarLogsRequest": "İstek Günlükleri", "sidebarLogsAccess": "Erişim Günlükleri", "sidebarLogsAction": "Eylem Günlükleri", "logRetention": "Kayıt Saklama", "logRetentionDescription": "Bu organizasyon için farklı türdeki günlüklerin ne kadar süre saklanacağını yönetin veya devre dışı bırakın", "requestLogsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek günlüklerini görüntüleyin", "requestAnalyticsDescription": "Bu organizasyondaki kaynaklar için ayrıntılı istek analizlerini görüntüleyin.", "logRetentionRequestLabel": "İstek Günlüğü Saklama", "logRetentionRequestDescription": "İstek günlüklerini ne kadar süre tutacağını belirle", "logRetentionAccessLabel": "Erişim Günlüğü Saklama", "logRetentionAccessDescription": "Erişim günlüklerini ne kadar süre tutacağını belirle", "logRetentionActionLabel": "Eylem Günlüğü Saklama", "logRetentionActionDescription": "Eylem günlüklerini ne kadar süre tutacağını belirle", "logRetentionDisabled": "Devre Dışı", "logRetention3Days": "3 gün", "logRetention7Days": "7 gün", "logRetention14Days": "14 gün", "logRetention30Days": "30 gün", "logRetention90Days": "90 gün", "logRetentionForever": "Sonsuza kadar", "logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu", "actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin", "accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin", "licenseRequiredToUse": "Bu özelliği kullanmak için bir Enterprise Edition lisansı gereklidir. Bu özellik ayrıca Pangolin Cloud'da da mevcuttur.", "ossEnterpriseEditionRequired": "Bu özelliği kullanmak için Enterprise Edition gereklidir. Bu özellik ayrıca Pangolin Cloud'da da mevcuttur.", "certResolver": "Sertifika Çözücü", "certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.", "selectCertResolver": "Sertifika Çözücü Seçin", "enterCustomResolver": "Özel Çözücü Girin", "preferWildcardCert": "Joker Sertifikayı Tercih Et", "unverified": "Doğrulanmadı", "domainSetting": "Alan Adı Ayarları", "domainSettingDescription": "Alan adı için ayarları yapılandırın", "preferWildcardCertDescription": "Joker karakter sertifikası oluşturmayı deneyin (doğru yapılandırılmış bir sertifika çözümleyici gerektirir).", "recordName": "Kayıt Adı", "auto": "Otomatik", "TTL": "TTL", "howToAddRecords": "Kayıtları Nasıl Ekleyebilirsiniz", "dnsRecord": "DNS Kayıtları", "required": "Gerekli", "domainSettingsUpdated": "Alan adına yönelik ayarlar başarıyla güncellendi", "orgOrDomainIdMissing": "Organizasyon veya Alan Adı Kimliği eksik", "loadingDNSRecords": "DNS kayıtları yükleniyor...", "olmUpdateAvailableInfo": "Olm'nin güncellenmiş bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", "client": "İstemci", "proxyProtocol": "Proxy Protokol Ayarları", "proxyProtocolDescription": "TCP hizmetleri için istemci IP adreslerini korumak amacıyla Proxy Protokolünü yapılandırın.", "enableProxyProtocol": "Proxy Protokolünü Etkinleştir", "proxyProtocolInfo": "TCP ara yüzlerini koruyarak istemci IP adreslerini saklayın", "proxyProtocolVersion": "Proxy Protokol Versiyonu", "version1": " Versiyon 1 (Önerilen)", "version2": "Versiyon 2", "versionDescription": "Versiyon 1 metin tabanlı ve yaygın olarak desteklenir. Versiyon 2 ise ikili ve daha verimlidir ama daha az uyumludur.", "warning": "Uyarı", "proxyProtocolWarning": "Arka uç uygulamanız, Proxy Protokol bağlantılarını kabul etmek üzere yapılandırılmalıdır. Arka ucunuz Proxy Protokolünü desteklemiyorsa, bunu etkinleştirmek tüm bağlantıları koparır. Traefik'ten gelen Proxy Protokol başlıklarına güvenecek şekilde arka ucunuzu yapılandırdığınızdan emin olun.", "restarting": "Yeniden Başlatılıyor...", "manual": "Manuel", "messageSupport": "Destek Mesajı Gönder", "supportNotAvailableTitle": "Destek Yok", "supportNotAvailableDescription": "Destek şu anda mevcut değil. Destek'e bir e-posta gönderebilirsiniz: support@pangolin.net.", "supportRequestSentTitle": "Destek İsteği Gönderildi", "supportRequestSentDescription": "Mesajınız başarıyla gönderildi.", "supportRequestFailedTitle": "İsteği Gönderme Başarısız", "supportRequestFailedDescription": "Destek isteği gönderilirken bir hata oluştu.", "supportSubjectRequired": "Konu gerekli", "supportSubjectMaxLength": "Konu en fazla 255 karakter olabilir", "supportMessageRequired": "Mesaj gerekli", "supportReplyTo": "Yanıtla", "supportSubject": "Konu", "supportSubjectPlaceholder": "Konu girin", "supportMessage": "Mesaj", "supportMessagePlaceholder": "Mesajınızı girin", "supportSending": "Gönderiliyor...", "supportSend": "Gönder", "supportMessageSent": "Mesaj Gönderildi!", "supportWillContact": "En kısa sürede size geri döneceğiz!", "selectLogRetention": "Kayıt saklama seç", "terms": "Şartlar", "privacy": "Gizlilik", "security": "Güvenlik", "docs": "Belgeler", "deviceActivation": "Cihaz aktivasyonu", "deviceCodeInvalidFormat": "Kod 9 karakter olmalı (ör. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Geçersiz veya süresi dolmuş kod", "deviceCodeVerifyFailed": "Cihaz kodu doğrulanamadı", "deviceCodeValidating": "Cihaz kodu doğrulanıyor...", "deviceCodeVerifying": "Cihaz yetkilendirme doğrulanıyor...", "signedInAs": "Olarak giriş yapıldı", "deviceCodeEnterPrompt": "Cihazda gösterilen kodu girin", "continue": "Devam Et", "deviceUnknownLocation": "Bilinmeyen konum", "deviceAuthorizationRequested": "Bu yetkilendirme {tarih} tarihinde {konum} konumundan talep edildi. Bu cihaza güvenmenizi sağlayın, çünkü hesap erişimine sahip olacaktır.", "deviceLabel": "Cihaz: {cihazadı}", "deviceWantsAccess": "hesabınıza erişmek istiyor", "deviceExistingAccess": "Mevcut erişim:", "deviceFullAccess": "Hesabınıza tam erişim", "deviceOrganizationsAccess": "Hesabınızın erişim hakkına sahip olduğu tüm organizasyonlara erişim", "deviceAuthorize": "{uygulamaAdi} yetkilendir", "deviceConnected": "Cihaz Bağlandı!", "deviceAuthorizedMessage": "Cihaz hesabınıza erişim yetkisine sahiptir. Lütfen istemci uygulamasına geri dönün.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "Cihazları Görüntüle", "viewDevicesDescription": "Bağlantılı cihazlarınızı yönetin", "noDevices": "Cihaz bulunamadı", "dateCreated": "Oluşturulma Tarihi", "unnamedDevice": "Adı Olmayan Cihaz", "deviceQuestionRemove": "Bu cihazı silmek istediğinizden emin misiniz?", "deviceMessageRemove": "Bu eylem geri alınamaz.", "deviceDeleteConfirm": "Cihazı Sil", "deleteDevice": "Cihazı Sil", "errorLoadingDevices": "Cihaz yüklenirken hata oluştu", "failedToLoadDevices": "Cihazlar yüklenemedi", "deviceDeleted": "Cihaz silindi", "deviceDeletedDescription": "Cihaz başarıyla silindi.", "errorDeletingDevice": "Cihaz silinirken hata", "failedToDeleteDevice": "Cihaz silinirken hata oluştu", "showColumns": "Sütunları Göster", "hideColumns": "Sütunları Gizle", "columnVisibility": "Sütun Görünürlüğü", "toggleColumn": "{columnName} sütununu aç/kapat", "allColumns": "Tüm Sütunlar", "defaultColumns": "Varsayılan Sütunlar", "customizeView": "Görünümü Özelleştir", "viewOptions": "Görünüm Seçenekleri", "selectAll": "Tümünü Seç", "selectNone": "Hiçbirini Seçme", "selectedResources": "Seçilen Kaynaklar", "enableSelected": "Seçilenleri Etkinleştir", "disableSelected": "Seçilenleri Devre Dışı Bırak", "checkSelectedStatus": "Seçilenlerin Durumunu Kontrol Et", "clients": "İstemciler", "accessClientSelect": "Makine istemcilerini seçiniz", "resourceClientDescription": "Bu kaynağa erişebilecek makine istemcileri", "regenerate": "Yeniden Üret", "credentials": "Kimlik Bilgileri", "savecredentials": "Kimlik Bilgilerini Kaydet", "regenerateCredentialsButton": "Kimlik Bilgilerini Yeniden Oluştur", "regenerateCredentials": "Kimlik Bilgilerini Yeniden Oluştur", "generatedcredentials": "Oluşturulan Kimlik Bilgileri", "copyandsavethesecredentials": "Bu kimlik bilgilerini kopyalayın ve kaydedin", "copyandsavethesecredentialsdescription": "Bu sayfadan ayrıldıktan sonra bu kimlik bilgileri tekrar gösterilmeyecek. Onları şimdi güvenli bir şekilde saklayın.", "credentialsSaved": "Kimlik Bilgileri Kaydedildi", "credentialsSavedDescription": "Kimlik bilgileri başarılı bir şekilde yeniden oluşturuldu ve kaydedildi.", "credentialsSaveError": "Kimlik Bilgileri Kayıt Hatası", "credentialsSaveErrorDescription": "Kimlik bilgilerini yeniden oluştururken ve kaydederken bir hata oluştu.", "regenerateCredentialsWarning": "Kimlik bilgilerini yeniden oluşturmak, öncekilerini geçersiz kılacak ve bağlantı kesintisine neden olacaktır. Bu kimlik bilgilerini kullanan yapılandırmaları güncellediğinizden emin olun.", "confirm": "Onayla", "regenerateCredentialsConfirmation": "Kimlik bilgilerini yeniden oluşturmak istediğinizden emin misiniz?", "endpoint": "Uç Nokta", "Id": "Kimlik", "SecretKey": "Gizli Anahtar", "niceId": "Güzel Kimlik", "niceIdUpdated": "Güzel Kimlik Güncellendi", "niceIdUpdatedSuccessfully": "Güzel Kimlik Başarıyla Güncellendi", "niceIdUpdateError": "Güzel Kimlik güncellenirken hata", "niceIdUpdateErrorDescription": "Güzel Kimlik güncellenirken bir hata oluştu.", "niceIdCannotBeEmpty": "Güzel Kimlik boş olamaz", "enterIdentifier": "Tanımlayıcıyı girin", "identifier": "Tanımlayıcı", "deviceLoginUseDifferentAccount": "Siz değil misiniz? Farklı bir hesap kullanın.", "deviceLoginDeviceRequestingAccessToAccount": "Bir cihaz bu hesaba erişim talep ediyor.", "loginSelectAuthenticationMethod": "Devam etmek için bir kimlik doğrulama yöntemi seçin.", "noData": "Veri Yok", "machineClients": "Makine İstemcileri", "install": "Yükle", "run": "Çalıştır", "clientNameDescription": "Daha sonra değiştirilebilecek istemcinin görünen adı.", "clientAddress": "İstemci Adresi (Gelişmiş)", "setupFailedToFetchSubnet": "Varsayılan alt ağ alınamadı", "setupSubnetAdvanced": "Alt Ağ (Gelişmiş)", "setupSubnetDescription": "Bu organizasyonun dahili ağı için alt ağ.", "setupUtilitySubnet": "Yardımcı Alt Ağ (Gelişmiş)", "setupUtilitySubnetDescription": "Bu kuruluşun alias adresleri ve DNS sunucusu için alt ağ.", "siteRegenerateAndDisconnect": "Yeniden Oluştur ve Bağlantıyı Kes", "siteRegenerateAndDisconnectConfirmation": "Kimlik bilgilerini yeniden oluşturmak ve bu sitenin bağlantısını kesmek istediğinizden emin misiniz?", "siteRegenerateAndDisconnectWarning": "Bu, kimlik bilgilerini yeniden oluşturacak ve sitenin bağlantısını anında kesecektir. Site yeni kimlik bilgilerle yeniden başlatılmalıdır.", "siteRegenerateCredentialsConfirmation": "Bu site için kimlik bilgilerini yeniden oluşturmak istediğinizden emin misiniz?", "siteRegenerateCredentialsWarning": "Bu, kimlik bilgilerini yeniden oluşturacak. Site manuel olarak yeniden başlatılana ve yeni kimlik bilgileri kullanılana kadar bağlı kalacak.", "clientRegenerateAndDisconnect": "Yeniden Oluştur ve Bağlantıyı Kes", "clientRegenerateAndDisconnectConfirmation": "Kimlik bilgilerini yeniden oluşturmak ve bu istemcinin bağlantısını kesmek istediğinizden emin misiniz?", "clientRegenerateAndDisconnectWarning": "Bu, kimlik bilgilerini yeniden oluşturacak ve istemcinin bağlantısını hemen kesecek. İstemci, yeni kimlik bilgilerle yeniden başlatılmalıdır.", "clientRegenerateCredentialsConfirmation": "Bu istemci için kimlik bilgilerini yeniden oluşturmak istediğinizden emin misiniz?", "clientRegenerateCredentialsWarning": "Bu, kimlik bilgilerini yeniden oluşturacak. İstemci, manuel olarak yeniden başlatılana ve yeni kimlik bilgileri kullanılana kadar bağlı kalacak.", "remoteExitNodeRegenerateAndDisconnect": "Yeniden Oluştur ve Bağlantıyı Kes", "remoteExitNodeRegenerateAndDisconnectConfirmation": "Kimlik bilgilerini yeniden oluşturmak ve bu uzak çıkış düğümünün bağlantısını kesmek istediğinizden emin misiniz?", "remoteExitNodeRegenerateAndDisconnectWarning": "Bu, kimlik bilgilerini yeniden oluşturacak ve hemen uzak çıkış düğümünün bağlantısını kesecek. Uzak çıkış düğümü, yeni kimlik bilgileri ile yeniden başlatılmalıdır.", "remoteExitNodeRegenerateCredentialsConfirmation": "Bu uzak çıkış düğümü için kimlik bilgilerini yeniden oluşturmak istediğinizden emin misiniz?", "remoteExitNodeRegenerateCredentialsWarning": "Bu, kimlik bilgilerini yeniden oluşturacak. Uzak çıkış düğümü, manuel olarak yeniden başlatılana ve yeni kimlik bilgiler kullanılana kadar bağlı kalacak.", "agent": "Aracı", "personalUseOnly": "Sadece Kişisel Kullanım", "loginPageLicenseWatermark": "Bu örnek yalnızca kişisel kullanım için lisanslıdır.", "instanceIsUnlicensed": "Bu örnek lisanssızdır.", "portRestrictions": "Port Kısıtlamaları", "allPorts": "Tümü", "custom": "Özel", "allPortsAllowed": "Tüm Portlar İzinli", "allPortsBlocked": "Tüm Portlar Engelli", "tcpPortsDescription": "Bu kaynak için izin verilen TCP portlarını belirtin. Tüm portlar için '*' kullanın, hepsini engellemek için boş bırakın veya virgülle ayrılmış port ve aralık listesi girin (ör. 80,443,8000-9000).", "udpPortsDescription": "Bu kaynak için izin verilen UDP portlarını belirtin. Tüm portlar için '*' kullanın, hepsini engellemek için boş bırakın veya virgülle ayrılmış port ve aralık listesi girin (ör. 53,123,500-600).", "organizationLoginPageTitle": "Kuruluş Giriş Sayfası", "organizationLoginPageDescription": "Bu kuruluş için giriş sayfasını özelleştirin", "resourceLoginPageTitle": "Kaynak Giriş Sayfası", "resourceLoginPageDescription": "Bağımsız kaynaklar için giriş sayfasını özelleştirin", "enterConfirmation": "Onayı girin", "blueprintViewDetails": "Detaylar", "defaultIdentityProvider": "Varsayılan Kimlik Sağlayıcı", "defaultIdentityProviderDescription": "Varsayılan bir kimlik sağlayıcı seçildiğinde, kullanıcı kimlik doğrulaması için otomatik olarak sağlayıcıya yönlendirilecektir.", "editInternalResourceDialogNetworkSettings": "Ağ Ayarları", "editInternalResourceDialogAccessPolicy": "Erişim Politikası", "editInternalResourceDialogAddRoles": "Roller Ekle", "editInternalResourceDialogAddUsers": "Kullanıcılar Ekle", "editInternalResourceDialogAddClients": "Müşteriler Ekle", "editInternalResourceDialogDestinationLabel": "Hedef", "editInternalResourceDialogDestinationDescription": "Dahili kaynak için hedef adresi belirtin. Seçilen moda bağlı olarak bu bir ana bilgisayar adı, IP adresi veya CIDR aralığı olabilir. Daha kolay tanımlama için isteğe bağlı olarak dahili bir DNS takma adı ayarlayın.", "editInternalResourceDialogPortRestrictionsDescription": "Belirtilen TCP/UDP portlarına erişimi kısıtlayın veya tüm portlara izin/engelleme verin.", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "Erişim Kontrolü", "editInternalResourceDialogAccessControlDescription": "Bağlandığında bu kaynağa erişimi olan roller, kullanıcılar ve makine müşterilerini kontrol edin. Yöneticiler her zaman erişime sahiptir.", "editInternalResourceDialogPortRangeValidationError": "Port aralığı, tüm portlar için \"*\" veya virgülle ayrılmış bir port ve aralık listesi olmalıdır (ör. \"80,443,8000-9000\"). Portlar 1 ile 65535 arasında olmalıdır.", "internalResourceAuthDaemonStrategy": "SSH Kimlik Doğrulama Daemon Yeri", "internalResourceAuthDaemonStrategyDescription": "SSH kimlik doğrulama sunucusunun nerede çalışacağını seçin: sitede (Newt) veya uzak bir ana bilgisayarda.", "internalResourceAuthDaemonDescription": "SSH kimlik doğrulama sunucusu, bu kaynak için SSH anahtar imzalama ve PAM kimlik doğrulamasını yapar. Sitede (Newt) veya ayrı bir uzak ana bilgisayarda çalışıp çalışmayacağını seçin. Daha fazla bilgi için belgeleri görün.", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "Strateji Seçin", "internalResourceAuthDaemonStrategyLabel": "Konum", "internalResourceAuthDaemonSite": "Sitede", "internalResourceAuthDaemonSiteDescription": "Kimlik doğrulama sunucusu sitede (Newt) çalışır.", "internalResourceAuthDaemonRemote": "Uzak Ana Bilgisayar", "internalResourceAuthDaemonRemoteDescription": "Kimlik doğrulama sunucusu, site olmayan bir ana bilgisayarda çalışır.", "internalResourceAuthDaemonPort": "Daemon Portu (isteğe bağlı)", "orgAuthWhatsThis": "Kuruluş kimliğimi nerede bulabilirim?", "learnMore": "Daha fazla bilgi", "backToHome": "Ana sayfaya geri dön", "needToSignInToOrg": "Kuruluşunuzun kimlik sağlayıcısını kullanmanız mı gerekiyor?", "maintenanceMode": "Bakım Modu", "maintenanceModeDescription": "Ziyaretçilere bir bakım sayfası gösterin", "maintenanceModeType": "Bakım Modu Türü", "showMaintenancePage": "Ziyaretçilere bir bakım sayfası gösterin", "enableMaintenanceMode": "Bakım Modunu Etkinleştir", "automatic": "Otomatik", "automaticModeDescription": "Tüm arka uç hedefleri kapalı veya sağlıksız olduğunda yalnızca bakım sayfasını gösterin. Sağlıklı en az bir hedef olduğu sürece kaynağınız normal şekilde çalışmaya devam eder.", "forced": "Zorunlu", "forcedModeDescription": "Arka plan sağlığına bakılmaksızın her zaman bakım sayfasını gösterin. Tüm erişimi engellemek istediğiniz planlı bakım için bunu kullanın.", "warning:": "Uyarı:", "forcedeModeWarning": "Tüm trafik bakım sayfasına yönlendirilecek. Arka plan kaynaklarınız herhangi bir isteği almayacaktır.", "pageTitle": "Sayfa Başlığı", "pageTitleDescription": "Bakım sayfasında gösterilen ana başlık", "maintenancePageMessage": "Bakım Mesajı", "maintenancePageMessagePlaceholder": "Yakında geri döneceğiz! Sitemiz şu anda planlı bakım altındadır.", "maintenancePageMessageDescription": "Bakımın detaylarını açıklayan mesaj", "maintenancePageTimeTitle": "Tahmini Tamamlanma Süresi (İsteğe Bağlı)", "maintenanceTime": "ör. 2 saat, 1 Kasım saat 17:00", "maintenanceEstimatedTimeDescription": "Bakımın ne zaman tamamlanmasını bekliyorsunuz", "editDomain": "Alan Adını Düzenle", "editDomainDescription": "Kaynak için bir alan adı seçin", "maintenanceModeDisabledTooltip": "Bu özelliği etkinleştirmek için geçerli bir lisans gereklidir.", "maintenanceScreenTitle": "Servis Geçici Olarak Kullanılamıyor", "maintenanceScreenMessage": "Şu anda teknik zorluklar yaşıyoruz. Lütfen yakında tekrar kontrol edin.", "maintenanceScreenEstimatedCompletion": "Tahmini Tamamlama:", "createInternalResourceDialogDestinationRequired": "Hedef gereklidir", "available": "Mevcut", "archived": "Arşivlenmiş", "noArchivedDevices": "Arşivlenmiş cihaz bulunamadı", "deviceArchived": "Cihaz arşivlendi", "deviceArchivedDescription": "Cihaz başarıyla arşivlendi.", "errorArchivingDevice": "Cihaz arşivleme hatası", "failedToArchiveDevice": "Cihaz arşivlenemedi", "deviceQuestionArchive": "Bu cihazı arşivlemek istediğinizden emin misiniz?", "deviceMessageArchive": "Cihaz arşivlenecek ve aktif cihazlar listenizden kaldırılacak.", "deviceArchiveConfirm": "Cihaz Arşivle", "archiveDevice": "Cihaz Arşivle", "archive": "Arşivle", "deviceUnarchived": "Cihaz arşivden çıkarıldı", "deviceUnarchivedDescription": "Cihaz başarıyla arşivden çıkarıldı.", "errorUnarchivingDevice": "Cihaz arşivden çıkartılamadı", "failedToUnarchiveDevice": "Cihaz arşivden çıkarılamadı", "unarchive": "Arşivden Çıkart", "archiveClient": "İstemci Arşivle", "archiveClientQuestion": "Bu istemciyi arşivlemek istediğinizden emin misiniz?", "archiveClientMessage": "İstemci arşivlenecek ve aktif istemciler listenizden çıkarılacak.", "archiveClientConfirm": "İstemci Arşivle", "blockClient": "İstemci Engelle", "blockClientQuestion": "Bu istemciyi engellemek istediğinizden emin misiniz?", "blockClientMessage": "Cihaz şu anda bağlıysa bağlantısı kesilecek. Cihazı daha sonra engelini kaldırabilirsiniz.", "blockClientConfirm": "İstemci Engelle", "active": "Aktif", "usernameOrEmail": "Kullanıcı adı veya E-posta", "selectYourOrganization": "Kuruluşunuzu seçin", "signInTo": "Giriş yapın", "signInWithPassword": "Şifre ile Devam Et", "noAuthMethodsAvailable": "Bu kuruluş için kullanılabilir kimlik doğrulama yöntemleri yok.", "enterPassword": "Şifrenizi girin", "enterMfaCode": "Authenticator uygulamanızdan kodu girin", "securityKeyRequired": "Giriş yapmak için güvenlik anahtarınızı kullanın.", "needToUseAnotherAccount": "Farklı bir hesap kullanmanız mı gerekiyor?", "loginLegalDisclaimer": "Aşağıdaki butonlara tıklayarak, Hizmet Şartları ve Gizlilik Politikası metinlerini okuduğunuzu ve anladığınızı kabul etmektesiniz.", "termsOfService": "Hizmet Şartları", "privacyPolicy": "Gizlilik Politikası", "userNotFoundWithUsername": "Bu kullanıcı adıyla eşleşen kullanıcı bulunamadı.", "verify": "Doğrula", "signIn": "Giriş Yap", "forgotPassword": "Şifreni mi unuttun?", "orgSignInTip": "Daha önce giriş yaptıysanız, yukarıda kullanıcı adınızı veya e-posta adresinizi girerek kuruluşunuzun kimlik sağlayıcısıyla kimlik doğrulaması yapabilirsiniz. Daha kolay!", "continueAnyway": "Yine de devam et", "dontShowAgain": "Tekrar gösterme", "orgSignInNotice": "Biliyor muydunuz?", "signupOrgNotice": "Giriş yapmaya mı çalışıyorsunuz?", "signupOrgTip": "Kuruluşunuzun kimlik sağlayıcısı aracılığıyla giriş yapmaya mı çalışıyorsunuz?", "signupOrgLink": "Bunun yerine kuruluşunuzla giriş yapın veya kaydolun", "verifyEmailLogInWithDifferentAccount": "Farklı Bir Hesap Kullan", "logIn": "Giriş Yap", "deviceInformation": "Cihaz Bilgisi", "deviceInformationDescription": "Cihaz ve temsilci hakkında bilgi", "deviceSecurity": "Cihaz Güvenliği", "deviceSecurityDescription": "Cihaz güvenliği durumu bilgisi", "platform": "Platform", "macosVersion": "macOS Sürümü", "windowsVersion": "Windows Sürümü", "iosVersion": "iOS Sürümü", "androidVersion": "Android Sürümü", "osVersion": "İşletim Sistemi Sürümü", "kernelVersion": "Çekirdek Sürümü", "deviceModel": "Cihaz Modeli", "serialNumber": "Seri Numarası", "hostname": "Ana Makine Adı", "firstSeen": "İlk Görüldü", "lastSeen": "Son Görüldü", "biometricsEnabled": "Biyometri Etkin", "diskEncrypted": "Disk Şifrelenmiş", "firewallEnabled": "Güvenlik Duvarı Etkin", "autoUpdatesEnabled": "Otomatik Güncellemeler Etkin", "tpmAvailable": "TPM Mevcut", "windowsAntivirusEnabled": "Antivirüs Etkinleştirildi", "macosSipEnabled": "Sistem Bütünlüğü Koruması (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "Güvenlik Duvarı Gizlilik Modu", "linuxAppArmorEnabled": "AppArmor", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "Cihaz bilgilerini ve ayarlarını görüntüleyin", "devicePendingApprovalDescription": "Bu cihaz onay bekliyor", "deviceBlockedDescription": "Bu cihaz şu anda engellidir. Engeli kaldırılmadığı sürece hiçbir kaynağa bağlanamayacaktır.", "unblockClient": "İstemci Engeli Kaldır", "unblockClientDescription": "Cihazın engeli kaldırıldı", "unarchiveClient": "İstemci Arşivini Kaldır", "unarchiveClientDescription": "Cihaz arşivden çıkarıldı", "block": "Engelle", "unblock": "Engelini Kaldır", "deviceActions": "Cihaz İşlemleri", "deviceActionsDescription": "Cihaz durumu ve erişimini yönetin", "devicePendingApprovalBannerDescription": "Bu cihaz onay bekliyor. Onaylanana kadar kaynaklara bağlanamayacak.", "connected": "Bağlandı", "disconnected": "Bağlantı Kesildi", "approvalsEmptyStateTitle": "Cihaz Onayları Etkin Değil", "approvalsEmptyStateDescription": "Kullanıcıların yeni cihazlara bağlanabilmeleri için yönetici onayı gerektiren rol cihaz onaylarını etkinleştirin.", "approvalsEmptyStateStep1Title": "Rollere Git", "approvalsEmptyStateStep1Description": "Cihaz onaylarını yapılandırmak için kuruluşunuzun rol ayarlarına gidin.", "approvalsEmptyStateStep2Title": "Cihaz Onaylarını Etkinleştir", "approvalsEmptyStateStep2Description": "Bir rolü düzenleyin ve 'Cihaz Onaylarını Gerektir' seçeneğini etkinleştirin. Bu role sahip kullanıcıların yeni cihazlar için yönetici onayına ihtiyacı olacaktır.", "approvalsEmptyStatePreviewDescription": "Önizleme: Etkinleştirildiğinde, bekleyen cihaz talepleri incelenmek üzere burada görünecektir.", "approvalsEmptyStateButtonText": "Rolleri Yönet" } ================================================ FILE: messages/zh-CN.json ================================================ { "setupCreate": "创建组织、站点和资源", "headerAuthCompatibilityInfo": "启用此功能以在身份验证令牌缺失时强制返回401未授权响应。对于不在没有服务器挑战的情况下不发送凭证的浏览器或特定HTTP库,这是必需的。", "headerAuthCompatibility": "扩展兼容性", "setupNewOrg": "新建组织", "setupCreateOrg": "创建组织", "setupCreateResources": "创建资源", "setupOrgName": "组织名称", "orgDisplayName": "这是组织的显示名称。", "orgId": "组织ID", "setupIdentifierMessage": "这是组织唯一的标识符。", "setupErrorIdentifier": "组织ID 已被使用。请另选一个。", "componentsErrorNoMemberCreate": "您目前不是任何组织的成员。创建组织以开始操作。", "componentsErrorNoMember": "您目前不是任何组织的成员。", "welcome": "欢迎使用 Pangolin", "welcomeTo": "欢迎来到", "componentsCreateOrg": "创建组织", "componentsMember": "您属于{count, plural, =0 {没有组织} one {一个组织} other {# 个组织}}。", "componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。", "dismiss": "忽略", "subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。", "subscriptionViolationViewBilling": "查看计费", "componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。", "componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。", "inviteErrorNotValid": "很抱歉,但看起来你试图访问的邀请尚未被接受或不再有效。", "inviteErrorUser": "很抱歉,但看起来你想要访问的邀请不是这个用户。", "inviteLoginUser": "请确保您以正确的用户登录。", "inviteErrorNoUser": "很抱歉,但看起来你想访问的邀请不是一个存在的用户。", "inviteCreateUser": "请先创建一个帐户。", "goHome": "返回首页", "inviteLogInOtherUser": "以不同的用户登录", "createAnAccount": "创建帐户", "inviteNotAccepted": "邀请未接受", "authCreateAccount": "创建一个帐户以开始", "authNoAccount": "没有账户?", "email": "电子邮件地址", "password": "密码", "confirmPassword": "确认密码", "createAccount": "创建帐户", "viewSettings": "查看设置", "delete": "删除", "name": "名称", "online": "在线", "offline": "离线的", "site": "站点", "dataIn": "数据输入", "dataOut": "数据输出", "connectionType": "连接类型", "tunnelType": "隧道类型", "local": "本地的", "edit": "编辑", "siteConfirmDelete": "确认删除站点", "siteDelete": "删除站点", "siteMessageRemove": "一旦移除,站点将无法访问。与站点相关的所有目标也将被移除。", "siteQuestionRemove": "您确定要从组织中删除该站点吗?", "siteManageSites": "管理站点", "siteDescription": "创建和管理站点,启用与私人网络的连接", "sitesBannerTitle": "连接任何网络", "sitesBannerDescription": "站点是连接到远程网络的链接,允许Pangolin为用户提供资源访问,无论是公共还是私人。可以在任何可以运行二进制文件或容器的地方安装站点网络连接器(Newt)以建立连接。", "sitesBannerButtonText": "安装站点", "approvalsBannerTitle": "批准或拒绝设备访问", "approvalsBannerDescription": "审核、批准或拒绝用户的设备访问请求。 当需要设备批准时,用户必须先获得管理员批准,然后他们的设备才能连接到您的组织资源。", "approvalsBannerButtonText": "了解更多", "siteCreate": "创建站点", "siteCreateDescription2": "按照下面的步骤创建和连接一个新站点", "siteCreateDescription": "创建一个新站点开始连接资源", "close": "关闭", "siteErrorCreate": "创建站点出错", "siteErrorCreateKeyPair": "找不到密钥对或站点默认值", "siteErrorCreateDefaults": "未找到站点默认值", "method": "方法", "siteMethodDescription": "这是您将如何显示连接。", "siteLearnNewt": "学习如何在您的系统上安装 Newt", "siteSeeConfigOnce": "您只能看到一次配置。", "siteLoadWGConfig": "正在载入 WireGuard 配置...", "siteDocker": "扩展 Docker 部署详细信息", "toggle": "切换", "dockerCompose": "Docker 配置", "dockerRun": "停靠栏", "siteLearnLocal": "本地站点不需要隧道连接,点击了解更多", "siteConfirmCopy": "我已经复制了配置信息", "searchSitesProgress": "搜索站点...", "siteAdd": "添加站点", "siteInstallNewt": "安装 Newt", "siteInstallNewtDescription": "在您的系统中运行 Newt", "WgConfiguration": "WireGuard 配置", "WgConfigurationDescription": "使用以下配置连接到网络", "operatingSystem": "操作系统", "commands": "命令", "recommended": "推荐", "siteNewtDescription": "为获得最佳用户体验,请使用 Newt。其底层采用 WireGuard 技术,可直接通过 Pangolin 控制台,使用局域网地址访问您私有网络中的资源。", "siteRunsInDocker": "在 Docker 中运行", "siteRunsInShell": "在 macOS 、 Linux 和 Windows 的 Shell 中运行", "siteErrorDelete": "删除站点出错", "siteErrorUpdate": "更新站点失败", "siteErrorUpdateDescription": "更新站点时出错。", "siteUpdated": "站点已更新", "siteUpdatedDescription": "网站已更新。", "siteGeneralDescription": "配置此站点的常规设置", "siteSettingDescription": "配置站点设置", "siteSetting": "{siteName} 设置", "siteNewtTunnel": "新站点 (推荐)", "siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。", "siteWg": "基本 WireGuard", "siteWgDescription": "使用任何 WireGuard 客户端来建立隧道。需要手动配置 NAT。", "siteWgDescriptionSaas": "使用任何WireGuard客户端建立隧道。需要手动配置NAT。仅适用于自托管节点。", "siteLocalDescription": "仅限本地资源。不需要隧道。", "siteLocalDescriptionSaas": "仅本地资源。没有隧道。仅在远程节点上可用。", "siteSeeAll": "查看所有站点", "siteTunnelDescription": "确定如何连接到站点", "siteNewtCredentials": "全权证书", "siteNewtCredentialsDescription": "站点如何通过服务器进行身份验证", "remoteNodeCredentialsDescription": "这是远程节点如何与服务器进行身份验证", "siteCredentialsSave": "保存证书", "siteCredentialsSaveDescription": "您只能看到一次。请确保将其复制并保存到一个安全的地方。", "siteInfo": "站点信息", "status": "状态", "shareTitle": "管理共享链接", "shareDescription": "创建可共享的链接,允许临时或永久访问代理资源", "shareSearch": "搜索共享链接...", "shareCreate": "创建共享链接", "shareErrorDelete": "删除链接失败", "shareErrorDeleteMessage": "删除链接时出错", "shareDeleted": "链接已删除", "shareDeletedDescription": "链接已删除", "shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。", "accessToken": "访问令牌", "usageExamples": "用法示例", "tokenId": "令牌 ID", "requestHeades": "请求头", "queryParameter": "查询参数", "importantNote": "重要提示", "shareImportantDescription": "出于安全考虑,建议尽可能在使用请求头传递参数,因为查询参数可能会被浏览器历史记录或服务器日志记录。", "token": "令牌", "shareTokenSecurety": "保持访问令牌的安全。请不要在公开可访问的区域或客户端代码中共享它。", "shareErrorFetchResource": "获取资源失败", "shareErrorFetchResourceDescription": "获取资源时出错", "shareErrorCreate": "无法创建共享链接", "shareErrorCreateDescription": "创建共享链接时出错", "shareCreateDescription": "任何具有此链接的人都可以访问资源", "shareTitleOptional": "标题 (可选)", "expireIn": "过期时间", "neverExpire": "永不过期", "shareExpireDescription": "过期时间是链接可以使用并提供对资源的访问时间。 此时间后,链接将不再工作,使用此链接的用户将失去对资源的访问。", "shareSeeOnce": "您只能看到一次此链接。请确保复制它。", "shareAccessHint": "任何具有此链接的人都可以访问该资源。小心地分享它。", "shareTokenUsage": "查看访问令牌使用情况", "createLink": "创建链接", "resourcesNotFound": "找不到资源", "resourceSearch": "搜索资源", "openMenu": "打开菜单", "resource": "资源", "title": "标题", "created": "已创建", "expires": "过期时间", "never": "永不过期", "shareErrorSelectResource": "请选择一个资源", "proxyResourceTitle": "管理公共资源", "proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源", "proxyResourcesBannerTitle": "基于Web的公共访问", "proxyResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知访问策略。", "clientResourceTitle": "管理私有资源", "clientResourceDescription": "创建和管理只能通过连接客户端访问的资源", "privateResourcesBannerTitle": "零信任的私人访问", "privateResourcesBannerDescription": "私人资源使用零信任安全性,确保只允许明确授予的用户和机器访问资源。可以连接用户设备或机器客户端,通过安全的虚拟专用网络访问这些资源。", "resourcesSearch": "搜索资源...", "resourceAdd": "添加资源", "resourceErrorDelte": "删除资源时出错", "authentication": "认证", "protected": "受到保护", "notProtected": "未受到保护", "resourceMessageRemove": "一旦删除,资源将不再可访问。与该资源相关的所有目标也将被删除。", "resourceQuestionRemove": "您确定要从组织中删除资源吗?", "resourceHTTP": "HTTPS 资源", "resourceHTTPDescription": "通过使用完全限定的域名的HTTPS代理请求。", "resourceRaw": "TCP/UDP 资源", "resourceRawDescription": "通过使用端口号的原始TCP/UDP代理请求。", "resourceRawDescriptionCloud": "正在使用端口号的 TCP/UDP 代理请求。请使用一个REMOTE", "resourceCreate": "创建资源", "resourceCreateDescription": "按照下面的步骤创建新资源", "resourceSeeAll": "查看所有资源", "resourceInfo": "资源信息", "resourceNameDescription": "这是资源的显示名称。", "siteSelect": "选择站点", "siteSearch": "搜索站点", "siteNotFound": "未找到站点。", "selectCountry": "选择国家", "searchCountries": "搜索国家...", "noCountryFound": "找不到国家。", "siteSelectionDescription": "此站点将为目标提供连接。", "resourceType": "资源类型", "resourceTypeDescription": "确定如何访问资源", "resourceHTTPSSettings": "HTTPS 设置", "resourceHTTPSSettingsDescription": "配置如何通过 HTTPS 访问资源", "domainType": "域类型", "subdomain": "子域名", "baseDomain": "根域名", "subdomnainDescription": "可访问资源的子域。", "resourceRawSettings": "TCP/UDP 设置", "resourceRawSettingsDescription": "配置如何通过 TCP/UDP 访问资源", "protocol": "协议", "protocolSelect": "选择协议", "resourcePortNumber": "端口号", "resourcePortNumberDescription": "代理请求的外部端口号。", "back": "后退", "cancel": "取消", "resourceConfig": "配置片段", "resourceConfigDescription": "复制并粘贴这些配置片段以设置 TCP/UDP 资源", "resourceAddEntrypoints": "Traefik: 添加入口点", "resourceExposePorts": "Gerbil:在 Docker Compose 中显示端口", "resourceLearnRaw": "学习如何配置 TCP/UDP 资源", "resourceBack": "返回资源", "resourceGoTo": "转到资源", "resourceDelete": "删除资源", "resourceDeleteConfirm": "确认删除资源", "visibility": "可见性", "enabled": "已启用", "disabled": "已禁用", "general": "概览", "generalSettings": "常规设置", "proxy": "代理服务器", "internal": "内部设置", "rules": "规则", "resourceSettingDescription": "配置资源上的设置", "resourceSetting": "{resourceName} 设置", "alwaysAllow": "旁路认证", "alwaysDeny": "屏蔽访问", "passToAuth": "传递至认证", "orgSettingsDescription": "配置组织设置", "orgGeneralSettings": "组织设置", "orgGeneralSettingsDescription": "管理机构的详细信息和配置", "saveGeneralSettings": "保存常规设置", "saveSettings": "保存设置", "orgDangerZone": "危险区域", "orgDangerZoneDescription": "一旦删除该组织,将无法恢复,请务必确认。", "orgDelete": "删除组织", "orgDeleteConfirm": "确认删除组织", "orgMessageRemove": "此操作不可逆,这将删除所有相关数据。", "orgMessageConfirm": "要确认,请在下面输入组织名称。", "orgQuestionRemove": "您确定要删除组织吗?", "orgUpdated": "组织已更新", "orgUpdatedDescription": "组织已更新。", "orgErrorUpdate": "更新组织失败", "orgErrorUpdateMessage": "更新组织时出错。", "orgErrorFetch": "获取组织失败", "orgErrorFetchMessage": "列出您的组织时出错", "orgErrorDelete": "删除组织失败", "orgErrorDeleteMessage": "删除组织时出错。", "orgDeleted": "组织已删除", "orgDeletedMessage": "组织及其数据已被删除。", "deleteAccount": "删除帐户", "deleteAccountDescription": "永久删除您的帐户、您拥有的所有组织以及这些组织中的所有数据。此操作无法撤消。", "deleteAccountButton": "删除帐户", "deleteAccountConfirmTitle": "删除帐户", "deleteAccountConfirmMessage": "这将永久擦除您的帐户、您拥有的所有组织以及这些组织中的所有数据。这不能撤消。", "deleteAccountConfirmString": "删除帐户", "deleteAccountSuccess": "账户已删除", "deleteAccountSuccessMessage": "您的帐户已被删除。", "deleteAccountError": "删除帐户失败", "deleteAccountPreviewAccount": "您的帐户", "deleteAccountPreviewOrgs": "您拥有的组织 (和所有数据)", "orgMissing": "缺少组织 ID", "orgMissingMessage": "没有组织ID,无法重新生成邀请。", "accessUsersManage": "管理用户", "accessUsersDescription": "邀请和管理访问此组织的用户", "accessUsersSearch": "搜索用户...", "accessUserCreate": "创建用户", "accessUserRemove": "删除用户", "username": "用户名", "identityProvider": "身份提供商", "role": "角色", "nameRequired": "名称是必填项", "accessRolesManage": "管理角色", "accessRolesDescription": "创建和管理组织中用户的角色", "accessRolesSearch": "搜索角色...", "accessRolesAdd": "添加角色", "accessRoleDelete": "删除角色", "accessApprovalsManage": "管理批准", "accessApprovalsDescription": "查看和管理待审批的组织访问权限", "description": "描述", "inviteTitle": "打开邀请", "inviteDescription": "管理其他用户加入机构的邀请", "inviteSearch": "搜索邀请...", "minutes": "分钟", "hours": "小时", "days": "天", "weeks": "周", "months": "月", "years": "年", "day": "{count, plural, other {# 天}}", "apiKeysTitle": "API 密钥", "apiKeysConfirmCopy2": "您必须确认您已复制 API 密钥。", "apiKeysErrorCreate": "创建 API 密钥出错", "apiKeysErrorSetPermission": "设置权限出错", "apiKeysCreate": "生成 API 密钥", "apiKeysCreateDescription": "为机构生成一个新的 API 密钥", "apiKeysGeneralSettings": "权限", "apiKeysGeneralSettingsDescription": "确定此 API 密钥可以做什么", "apiKeysList": "新 API 密钥", "apiKeysSave": "保存 API 密钥", "apiKeysSaveDescription": "该信息仅会显示一次,请确保将其复制到安全的位置。", "apiKeysInfo": "API 密钥是:", "apiKeysConfirmCopy": "我已复制 API 密钥", "generate": "生成", "done": "完成", "apiKeysSeeAll": "查看所有 API 密钥", "apiKeysPermissionsErrorLoadingActions": "加载 API 密钥操作时出错", "apiKeysPermissionsErrorUpdate": "设置权限出错", "apiKeysPermissionsUpdated": "权限已更新", "apiKeysPermissionsUpdatedDescription": "权限已更新。", "apiKeysPermissionsGeneralSettings": "权限", "apiKeysPermissionsGeneralSettingsDescription": "确定此 API 密钥可以做什么", "apiKeysPermissionsSave": "保存权限", "apiKeysPermissionsTitle": "权限", "apiKeys": "API 密钥", "searchApiKeys": "搜索 API 密钥...", "apiKeysAdd": "生成 API 密钥", "apiKeysErrorDelete": "删除 API 密钥出错", "apiKeysErrorDeleteMessage": "删除 API 密钥出错", "apiKeysQuestionRemove": "您确定要从组织中删除 API 密钥吗?", "apiKeysMessageRemove": "一旦删除,此API密钥将无法被使用。", "apiKeysDeleteConfirm": "确认删除 API 密钥", "apiKeysDelete": "删除 API 密钥", "apiKeysManage": "管理 API 密钥", "apiKeysDescription": "API 密钥用于认证集成 API", "apiKeysSettings": "{apiKeyName} 设置", "userTitle": "管理所有用户", "userDescription": "查看和管理系统中的所有用户", "userAbount": "关于用户管理", "userAbountDescription": "此表格显示系统中所有根用户对象。每个用户可能属于多个组织。 从组织中删除用户不会删除其根用户对象 - 他们将保留在系统中。 要从系统中完全删除用户,您必须使用此表格中的删除操作删除其根用户对象。", "userServer": "服务器用户", "userSearch": "搜索服务器用户...", "userErrorDelete": "删除用户时出错", "userDeleteConfirm": "确认删除用户", "userDeleteServer": "从服务器删除用户", "userMessageRemove": "该用户将被从所有组织中删除并完全从服务器中删除。", "userQuestionRemove": "您确定要从服务器永久删除用户吗?", "licenseKey": "许可证密钥", "valid": "有效", "numberOfSites": "站点数量", "licenseKeySearch": "搜索许可证密钥...", "licenseKeyAdd": "添加许可证密钥", "type": "类型", "licenseKeyRequired": "需要许可证密钥", "licenseTermsAgree": "您必须同意许可条款", "licenseErrorKeyLoad": "加载许可证密钥失败", "licenseErrorKeyLoadDescription": "加载许可证密钥时出错。", "licenseErrorKeyDelete": "删除许可证密钥失败", "licenseErrorKeyDeleteDescription": "删除许可证密钥时出错。", "licenseKeyDeleted": "许可证密钥已删除", "licenseKeyDeletedDescription": "许可证密钥已被删除。", "licenseErrorKeyActivate": "激活许可证密钥失败", "licenseErrorKeyActivateDescription": "激活许可证密钥时出错。", "licenseAbout": "关于许可协议", "communityEdition": "社区版", "licenseAboutDescription": "这是针对商业环境中使用Pangolin的商业和企业用户。 如果您正在使用 Pangolin 供个人使用,您可以忽略此部分。", "licenseKeyActivated": "授权密钥已激活", "licenseKeyActivatedDescription": "已成功激活许可证密钥。", "licenseErrorKeyRecheck": "重新检查许可证密钥失败", "licenseErrorKeyRecheckDescription": "重新检查许可证密钥时出错。", "licenseErrorKeyRechecked": "重新检查许可证密钥", "licenseErrorKeyRecheckedDescription": "已重新检查所有许可证密钥", "licenseActivateKey": "激活许可证密钥", "licenseActivateKeyDescription": "输入一个许可密钥来激活它。", "licenseActivate": "激活许可证", "licenseAgreement": "通过检查此框,您确认您已经阅读并同意与您的许可证密钥相关的许可条款。", "fossorialLicense": "查看Fossorial Commercial License和订阅条款", "licenseMessageRemove": "这将删除许可证密钥和它授予的所有相关权限。", "licenseMessageConfirm": "要确认,请在下面输入许可证密钥。", "licenseQuestionRemove": "您确定要删除许可证密钥?", "licenseKeyDelete": "删除许可证密钥", "licenseKeyDeleteConfirm": "确认删除许可证密钥", "licenseTitle": "管理许可证状态", "licenseTitleDescription": "查看和管理系统中的许可证密钥", "licenseHost": "主机许可证", "licenseHostDescription": "管理主机的主许可证密钥。", "licensedNot": "未授权", "hostId": "主机 ID", "licenseReckeckAll": "重新检查所有密钥", "licenseSiteUsage": "站点使用情况", "licenseSiteUsageDecsription": "查看使用此许可的站点数量。", "licenseNoSiteLimit": "使用未经许可主机的站点数量没有限制。", "licensePurchase": "购买许可证", "licensePurchaseSites": "购买更多站点", "licenseSitesUsedMax": "使用了 {usedSites}/{maxSites} 个站点", "licenseSitesUsed": "{count, plural, =0 {# 站点} one {# 站点} other {# 站点}}", "licensePurchaseDescription": "请选择您希望 {selectedMode, select, license {直接购买许可证,您可以随时增加更多站点。} other {为现有许可证购买更多站点}}", "licenseFee": "许可证费用", "licensePriceSite": "每个站点的价格", "total": "总计", "licenseContinuePayment": "继续付款", "pricingPage": "定价页面", "pricingPortal": "前往付款页面", "licensePricingPage": "关于最新的价格和折扣,请访问 ", "invite": "邀请", "inviteRegenerate": "重新生成邀请", "inviteRegenerateDescription": "撤销以前的邀请并创建一个新的邀请", "inviteRemove": "移除邀请", "inviteRemoveError": "删除邀请失败", "inviteRemoveErrorDescription": "删除邀请时出错。", "inviteRemoved": "邀请已删除", "inviteRemovedDescription": "为 {email} 创建的邀请已删除", "inviteQuestionRemove": "您确定要删除邀请吗?", "inviteMessageRemove": "一旦删除,这个邀请将不再有效。", "inviteMessageConfirm": "要确认,请在下面输入邀请的电子邮件地址。", "inviteQuestionRegenerate": "您确定要重新邀请 {email} 吗?这将会撤销掉之前的邀请", "inviteRemoveConfirm": "确认删除邀请", "inviteRegenerated": "重新生成邀请", "inviteSent": "邀请邮件已成功发送至 {email}。", "inviteSentEmail": "发送电子邮件通知给用户", "inviteGenerate": "已为 {email} 创建新的邀请。", "inviteDuplicateError": "重复的邀请", "inviteDuplicateErrorDescription": "此用户的邀请已存在。", "inviteRateLimitError": "超出速率限制", "inviteRateLimitErrorDescription": "您超过了每小时3次再生的限制。请稍后再试。", "inviteRegenerateError": "重新生成邀请失败", "inviteRegenerateErrorDescription": "重新生成邀请时出错。", "inviteValidityPeriod": "有效期", "inviteValidityPeriodSelect": "选择有效期", "inviteRegenerateMessage": "邀请已重新生成。用户必须访问下面的链接才能接受邀请。", "inviteRegenerateButton": "重新生成", "expiresAt": "到期于", "accessRoleUnknown": "未知角色", "placeholder": "占位符", "userErrorOrgRemove": "删除用户失败", "userErrorOrgRemoveDescription": "删除用户时出错。", "userOrgRemoved": "用户已删除", "userOrgRemovedDescription": "已将 {email} 从组织中移除。", "userQuestionOrgRemove": "您确定要从组织中删除此用户吗?", "userMessageOrgRemove": "一旦删除,这个用户将不再能够访问组织。 你总是可以稍后重新邀请他们,但他们需要再次接受邀请。", "userRemoveOrgConfirm": "确认删除用户", "userRemoveOrg": "从组织中删除用户", "users": "用户", "accessRoleMember": "成员", "accessRoleOwner": "所有者", "userConfirmed": "已确认", "idpNameInternal": "内部设置", "emailInvalid": "无效的电子邮件地址", "inviteValidityDuration": "请选择持续时间", "accessRoleSelectPlease": "请选择一个角色", "usernameRequired": "必须输入用户名", "idpSelectPlease": "请选择身份提供商", "idpGenericOidc": "通用的 OAuth2/OIDC 提供商。", "accessRoleErrorFetch": "获取角色失败", "accessRoleErrorFetchDescription": "获取角色时出错", "idpErrorFetch": "获取身份提供者失败", "idpErrorFetchDescription": "获取身份提供者时出错", "userErrorExists": "用户已存在", "userErrorExistsDescription": "此用户已经是组织成员。", "inviteError": "邀请用户失败", "inviteErrorDescription": "邀请用户时出错", "userInvited": "用户邀请", "userInvitedDescription": "用户已被成功邀请。", "userErrorCreate": "创建用户失败", "userErrorCreateDescription": "创建用户时出错", "userCreated": "用户已创建", "userCreatedDescription": "用户已成功创建。", "userTypeInternal": "内部用户", "userTypeInternalDescription": "邀请用户直接加入组织。", "userTypeExternal": "外部用户", "userTypeExternalDescription": "创建一个具有外部身份提供商的用户。", "accessUserCreateDescription": "按照下面的步骤创建一个新用户", "userSeeAll": "查看所有用户", "userTypeTitle": "用户类型", "userTypeDescription": "确定如何创建用户", "userSettings": "用户信息", "userSettingsDescription": "输入新用户的详细信息", "inviteEmailSent": "发送邀请邮件给用户", "inviteValid": "有效", "selectDuration": "选择持续时间", "selectResource": "选择资源", "filterByResource": "按资源过滤", "selectApprovalState": "选择审批状态", "filterByApprovalState": "按批准状态过滤", "approvalListEmpty": "无批准", "approvalState": "审批状态", "approvalLoadMore": "加载更多", "loadingApprovals": "正在加载批准", "approve": "批准", "approved": "已批准", "denied": "被拒绝", "deniedApproval": "拒绝批准", "all": "所有", "deny": "拒绝", "viewDetails": "查看详情", "requestingNewDeviceApproval": "请求了一个新设备", "resetFilters": "重置过滤器", "totalBlocked": "被Pangolin阻止的请求", "totalRequests": "总请求", "requestsByCountry": "请求按国家", "requestsByDay": "按日请求", "blocked": "已阻止", "allowed": "允许的", "topCountries": "顶级国家", "accessRoleSelect": "选择角色", "inviteEmailSentDescription": "一封电子邮件已经发送给用户,带有下面的访问链接。他们必须访问该链接才能接受邀请。", "inviteSentDescription": "用户已被邀请。他们必须访问下面的链接才能接受邀请。", "inviteExpiresIn": "邀请将在{days, plural, other {# 天}}后过期。", "idpTitle": "身份提供商", "idpSelect": "为外部用户选择身份提供商", "idpNotConfigured": "没有配置身份提供者。请在创建外部用户之前配置身份提供者。", "usernameUniq": "这必须匹配所选身份提供者中存在的唯一用户名。", "emailOptional": "电子邮件(可选)", "nameOptional": "名称(可选)", "accessControls": "访问控制", "userDescription2": "管理此用户的设置", "accessRoleErrorAdd": "添加用户到角色失败", "accessRoleErrorAddDescription": "添加用户到角色时出错。", "userSaved": "用户已保存", "userSavedDescription": "用户已更新。", "autoProvisioned": "自动设置", "autoProvisionedDescription": "允许此用户由身份提供商自动管理", "accessControlsDescription": "管理此用户在组织中可以访问和做什么", "accessControlsSubmit": "保存访问控制", "roles": "角色", "accessUsersRoles": "管理用户和角色", "accessUsersRolesDescription": "邀请用户加入角色来管理访问组织", "key": "关键字", "createdAt": "创建于", "proxyErrorInvalidHeader": "无效的自定义主机头值。使用域名格式,或将空保存为取消自定义主机头。", "proxyErrorTls": "无效的 TLS 服务器名称。使用域名格式,或保存空以删除 TLS 服务器名称。", "proxyEnableSSL": "启用 SSL", "proxyEnableSSLDescription": "启用 SSL/TLS 加密以确保目标的 HTTPS 连接。", "target": "Target", "configureTarget": "配置目标", "targetErrorFetch": "获取目标失败", "targetErrorFetchDescription": "获取目标时出错", "siteErrorFetch": "获取资源失败", "siteErrorFetchDescription": "获取资源时出错", "targetErrorDuplicate": "重复的目标", "targetErrorDuplicateDescription": "具有这些设置的目标已存在", "targetWireGuardErrorInvalidIp": "无效的目标IP", "targetWireGuardErrorInvalidIpDescription": "目标IP必须在站点子网内", "targetsUpdated": "目标已更新", "targetsUpdatedDescription": "目标和设置更新成功", "targetsErrorUpdate": "更新目标失败", "targetsErrorUpdateDescription": "更新目标时出错", "targetTlsUpdate": "TLS 设置已更新", "targetTlsUpdateDescription": "已成功更新 TLS 设置", "targetErrorTlsUpdate": "更新 TLS 设置失败", "targetErrorTlsUpdateDescription": "更新 TLS 设置时出错", "proxyUpdated": "代理设置已更新", "proxyUpdatedDescription": "已成功更新代理设置", "proxyErrorUpdate": "更新代理设置失败", "proxyErrorUpdateDescription": "更新代理设置时出错", "targetAddr": "主机", "targetPort": "端口", "targetProtocol": "协议", "targetTlsSettings": "安全连接配置", "targetTlsSettingsDescription": "配置资源的 SSL/TLS 设置", "targetTlsSettingsAdvanced": "高级TLS设置", "targetTlsSni": "TLS 服务器名称", "targetTlsSniDescription": "SNI使用的 TLS 服务器名称。留空使用默认值。", "targetTlsSubmit": "保存设置", "targets": "目标配置", "targetsDescription": "设置目标路由流量到后端服务", "targetStickySessions": "启用置顶会话", "targetStickySessionsDescription": "将连接保持在同一个后端目标的整个会话中。", "methodSelect": "选择方法", "targetSubmit": "添加目标", "targetNoOne": "此资源没有任何目标。添加目标来配置向后端发送请求的位置。", "targetNoOneDescription": "在上面添加多个目标将启用负载平衡。", "targetsSubmit": "保存目标", "addTarget": "添加目标", "targetErrorInvalidIp": "无效的 IP 地址", "targetErrorInvalidIpDescription": "请输入有效的IP地址或主机名", "targetErrorInvalidPort": "无效的端口", "targetErrorInvalidPortDescription": "请输入有效的端口号", "targetErrorNoSite": "没有选择站点", "targetErrorNoSiteDescription": "请选择目标站点", "targetCreated": "目标已创建", "targetCreatedDescription": "目标已成功创建", "targetErrorCreate": "创建目标失败", "targetErrorCreateDescription": "创建目标时出错", "tlsServerName": "TLS 服务器名称", "tlsServerNameDescription": "SNI使用的 TLS 服务器名称", "save": "保存", "proxyAdditional": "附加代理设置", "proxyAdditionalDescription": "配置资源如何处理代理设置", "proxyCustomHeader": "自定义主机标题", "proxyCustomHeaderDescription": "代理请求时设置的主机头。留空则使用默认值。", "proxyAdditionalSubmit": "保存代理设置", "subnetMaskErrorInvalid": "子网掩码无效。必须在 0 和 32 之间。", "ipAddressErrorInvalidFormat": "无效的 IP 地址格式", "ipAddressErrorInvalidOctet": "无效的 IP 地址", "path": "路径", "matchPath": "匹配路径", "ipAddressRange": "IP 范围", "rulesErrorFetch": "获取规则失败", "rulesErrorFetchDescription": "获取规则时出错", "rulesErrorDuplicate": "复制规则", "rulesErrorDuplicateDescription": "带有这些设置的规则已存在", "rulesErrorInvalidIpAddressRange": "无效的 CIDR", "rulesErrorInvalidIpAddressRangeDescription": "请输入一个有效的 CIDR 值", "rulesErrorInvalidUrl": "无效的 URL 路径", "rulesErrorInvalidUrlDescription": "请输入一个有效的 URL 路径值", "rulesErrorInvalidIpAddress": "无效的 IP", "rulesErrorInvalidIpAddressDescription": "请输入一个有效的IP地址", "rulesErrorUpdate": "更新规则失败", "rulesErrorUpdateDescription": "更新规则时出错", "rulesUpdated": "启用规则", "rulesUpdatedDescription": "规则已更新", "rulesMatchIpAddressRangeDescription": "以 CIDR 格式输入地址(如:103.21.244.0/22)", "rulesMatchIpAddress": "输入IP地址(例如,103.21.244.12)", "rulesMatchUrl": "输入一个 URL 路径或模式(例如/api/v1/todos 或 /api/v1/*)", "rulesErrorInvalidPriority": "无效的优先级", "rulesErrorInvalidPriorityDescription": "请输入一个有效的优先级", "rulesErrorDuplicatePriority": "重复的优先级", "rulesErrorDuplicatePriorityDescription": "请输入唯一的优先级", "ruleUpdated": "规则已更新", "ruleUpdatedDescription": "规则更新成功", "ruleErrorUpdate": "操作失败", "ruleErrorUpdateDescription": "保存过程中发生错误", "rulesPriority": "优先权", "rulesAction": "行为", "rulesMatchType": "匹配类型", "value": "值", "rulesAbout": "关于规则", "rulesAboutDescription": "规则允许您根据一组标准控制对资源的访问。 您可以创建规则允许或拒绝基于IP地址或 URL 路径的访问。", "rulesActions": "行动", "rulesActionAlwaysAllow": "总是允许:绕过所有身份验证方法", "rulesActionAlwaysDeny": "总是拒绝:阻止所有请求;无法尝试验证", "rulesActionPassToAuth": "传递至认证:允许尝试身份验证方法", "rulesMatchCriteria": "匹配条件", "rulesMatchCriteriaIpAddress": "匹配一个指定的 IP 地址", "rulesMatchCriteriaIpAddressRange": "在 CIDR 符号中匹配一系列IP地址", "rulesMatchCriteriaUrl": "匹配一个 URL 路径或模式", "rulesEnable": "启用规则", "rulesEnableDescription": "启用或禁用此资源的规则评估", "rulesResource": "资源规则配置", "rulesResourceDescription": "配置规则来控制对资源的访问", "ruleSubmit": "添加规则", "rulesNoOne": "没有规则。使用表单添加规则。", "rulesOrder": "规则按优先顺序评定。", "rulesSubmit": "保存规则", "resourceErrorCreate": "创建资源时出错", "resourceErrorCreateDescription": "创建资源时出错", "resourceErrorCreateMessage": "创建资源时发生错误:", "resourceErrorCreateMessageDescription": "发生意外错误", "sitesErrorFetch": "获取站点出错", "sitesErrorFetchDescription": "获取站点时出错", "domainsErrorFetch": "获取域名出错", "domainsErrorFetchDescription": "获取域时出错", "none": "无", "unknown": "未知", "resources": "资源", "resourcesDescription": "资源是在私人网络上运行的应用程序的代理。在您的私人网络上为任意HTTP/HTTPS或raw TCP/UDP服务创建资源。 每个资源必须连接到一个站点,以便通过加密的 WireGuard 隧道启用私密安全连接。", "resourcesWireGuardConnect": "采用 WireGuard 提供的加密安全连接", "resourcesMultipleAuthenticationMethods": "配置多个身份验证方法", "resourcesUsersRolesAccess": "基于用户和角色的访问控制", "resourcesErrorUpdate": "切换资源失败", "resourcesErrorUpdateDescription": "更新资源时出错", "access": "访问权限", "accessControl": "访问控制", "shareLink": "{resource} 的分享链接", "resourceSelect": "选择资源", "shareLinks": "分享链接", "share": "分享链接", "shareDescription2": "创建资源的可共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。", "shareEasyCreate": "轻松创建和分享", "shareConfigurableExpirationDuration": "可配置的过期时间", "shareSecureAndRevocable": "安全和可撤销的", "nameMin": "名称长度必须大于 {len} 字符。", "nameMax": "名称长度必须小于 {len} 字符。", "sitesConfirmCopy": "请确认您已经复制了配置。", "unknownCommand": "未知命令", "newtErrorFetchReleases": "无法获取版本信息: {err}", "newtErrorFetchLatest": "无法获取最新版信息: {err}", "newtEndpoint": "Endpoint", "newtId": "ID", "newtSecretKey": "密钥", "architecture": "架构", "sites": "站点", "siteWgAnyClients": "使用任何 WireGuard 客户端连接。您必须使用对等IP解决内部资源问题。", "siteWgCompatibleAllClients": "与所有WireGuard客户端兼容", "siteWgManualConfigurationRequired": "需要手动配置", "userErrorNotAdminOrOwner": "用户不是管理员或所有者", "pangolinSettings": "设置 - Pangolin", "accessRoleYour": "您的角色:", "accessRoleSelect2": "选择角色", "accessUserSelect": "选择用户", "otpEmailEnter": "输入电子邮件", "otpEmailEnterDescription": "在输入字段输入后按回车键添加电子邮件。", "otpEmailErrorInvalid": "无效的邮箱地址。通配符(*)必须占据整个开头部分。", "otpEmailSmtpRequired": "需要先配置SMTP", "otpEmailSmtpRequiredDescription": "必须在服务器上启用SMTP才能使用一次性密码验证。", "otpEmailTitle": "一次性密码", "otpEmailTitleDescription": "资源访问需要基于电子邮件的身份验证", "otpEmailWhitelist": "电子邮件白名单", "otpEmailWhitelistList": "白名单邮件", "otpEmailWhitelistListDescription": "只有拥有这些电子邮件地址的用户才能访问此资源。 他们将被提示输入一次性密码发送到他们的电子邮件。 通配符 (*@example.com) 可以用来允许来自一个域名的任何电子邮件地址。", "otpEmailWhitelistSave": "保存白名单", "passwordAdd": "添加密码", "passwordRemove": "删除密码", "pincodeAdd": "添加 PIN 码", "pincodeRemove": "移除 PIN 码", "resourceAuthMethods": "身份验证方法", "resourceAuthMethodsDescriptions": "允许通过额外的认证方法访问资源", "resourceAuthSettingsSave": "保存成功", "resourceAuthSettingsSaveDescription": "已保存身份验证设置", "resourceErrorAuthFetch": "获取数据失败", "resourceErrorAuthFetchDescription": "获取数据时出错", "resourceErrorPasswordRemove": "删除资源密码出错", "resourceErrorPasswordRemoveDescription": "删除资源密码时出错", "resourceErrorPasswordSetup": "设置资源密码出错", "resourceErrorPasswordSetupDescription": "设置资源密码时出错", "resourceErrorPincodeRemove": "删除资源固定码时出错", "resourceErrorPincodeRemoveDescription": "删除资源PIN码时出错", "resourceErrorPincodeSetup": "设置资源 PIN 码时出错", "resourceErrorPincodeSetupDescription": "设置资源 PIN 码时发生错误", "resourceErrorUsersRolesSave": "设置角色失败", "resourceErrorUsersRolesSaveDescription": "设置角色时出错", "resourceErrorWhitelistSave": "保存白名单失败", "resourceErrorWhitelistSaveDescription": "保存白名单时出错", "resourcePasswordSubmit": "启用密码保护", "resourcePasswordProtection": "密码保护 {status}", "resourcePasswordRemove": "已删除资源密码", "resourcePasswordRemoveDescription": "已成功删除资源密码", "resourcePasswordSetup": "设置资源密码", "resourcePasswordSetupDescription": "已成功设置资源密码", "resourcePasswordSetupTitle": "设置密码", "resourcePasswordSetupTitleDescription": "设置密码来保护此资源", "resourcePincode": "PIN 码", "resourcePincodeSubmit": "启用 PIN 码保护", "resourcePincodeProtection": "PIN 码保护 {status}", "resourcePincodeRemove": "资源 PIN 码已删除", "resourcePincodeRemoveDescription": "已成功删除资源 PIN 码", "resourcePincodeSetup": "资源 PIN 码已设置", "resourcePincodeSetupDescription": "资源 PIN 码已成功设置", "resourcePincodeSetupTitle": "设置 PIN 码", "resourcePincodeSetupTitleDescription": "设置 PIN 码来保护此资源", "resourceRoleDescription": "管理员总是可以访问此资源。", "resourceUsersRoles": "访问控制", "resourceUsersRolesDescription": "配置用户和角色可以访问此资源", "resourceUsersRolesSubmit": "保存访问控制", "resourceWhitelistSave": "保存成功", "resourceWhitelistSaveDescription": "白名单设置已保存", "ssoUse": "使用平台 SSO", "ssoUseDescription": "对于所有启用此功能的资源,现有用户只需登录一次。", "proxyErrorInvalidPort": "无效的端口号", "subdomainErrorInvalid": "无效的子域", "domainErrorFetch": "获取域名失败", "domainErrorFetchDescription": "获取域名时出错", "resourceErrorUpdate": "更新资源失败", "resourceErrorUpdateDescription": "更新资源时出错", "resourceUpdated": "资源已更新", "resourceUpdatedDescription": "资源已成功更新", "resourceErrorTransfer": "转移资源失败", "resourceErrorTransferDescription": "转移资源时出错", "resourceTransferred": "资源已传输", "resourceTransferredDescription": "资源已成功传输", "resourceErrorToggle": "切换资源失败", "resourceErrorToggleDescription": "更新资源时出错", "resourceVisibilityTitle": "可见性", "resourceVisibilityTitleDescription": "完全启用或禁用资源可见性", "resourceGeneral": "常规设置", "resourceGeneralDescription": "配置此资源的常规设置", "resourceEnable": "启用资源", "resourceTransfer": "转移资源", "resourceTransferDescription": "将此资源转移到另一个站点", "resourceTransferSubmit": "转移资源", "siteDestination": "目标站点", "searchSites": "搜索站点", "countries": "国家", "accessRoleCreate": "创建角色", "accessRoleCreateDescription": "创建一个新角色来分组用户并管理他们的权限。", "accessRoleEdit": "编辑角色", "accessRoleEditDescription": "编辑角色信息。", "accessRoleCreateSubmit": "创建角色", "accessRoleCreated": "角色已创建", "accessRoleCreatedDescription": "角色已成功创建。", "accessRoleErrorCreate": "创建角色失败", "accessRoleErrorCreateDescription": "创建角色时出错。", "accessRoleUpdateSubmit": "更新角色", "accessRoleUpdated": "角色已更新", "accessRoleUpdatedDescription": "角色已成功更新。", "accessApprovalUpdated": "审批已处理", "accessApprovalApprovedDescription": "将审批请求决定设置为已批准。", "accessApprovalDeniedDescription": "设置审批请求决定被拒绝。", "accessRoleErrorUpdate": "更新角色失败", "accessRoleErrorUpdateDescription": "更新角色时出错。", "accessApprovalErrorUpdate": "处理审核失败", "accessApprovalErrorUpdateDescription": "处理批准时出错。", "accessRoleErrorNewRequired": "需要新角色", "accessRoleErrorRemove": "删除角色失败", "accessRoleErrorRemoveDescription": "删除角色时出错。", "accessRoleName": "角色名称", "accessRoleQuestionRemove": "您即将删除 `{name}` 角色。此操作无法撤销。", "accessRoleRemove": "删除角色", "accessRoleRemoveDescription": "从组织中删除角色", "accessRoleRemoveSubmit": "删除角色", "accessRoleRemoved": "角色已删除", "accessRoleRemovedDescription": "角色已成功删除。", "accessRoleRequiredRemove": "删除此角色之前,请选择一个新角色来转移现有成员。", "network": "网络", "manage": "管理", "sitesNotFound": "未找到站点。", "pangolinServerAdmin": "服务器管理员 - Pangolin", "licenseTierProfessional": "专业许可证", "licenseTierEnterprise": "企业许可证", "licenseTierPersonal": "个人许可证", "licensed": "已授权", "yes": "是", "no": "否", "sitesAdditional": "其他站点", "licenseKeys": "许可证密钥", "sitestCountDecrease": "减少站点数量", "sitestCountIncrease": "增加站点数量", "idpManage": "管理身份提供商", "idpManageDescription": "查看和管理系统中的身份提供商", "idpGlobalModeBanner": "此服务器上禁用了每个组织的身份提供商(Idps)。 它正在使用全局IdP(所有组织共享)。在 管理面板中管理全局IdP。 要启用每个组织的 IdP,请编辑服务器配置并将 IdP 模式设置为 org。 请参阅文档。 如果您想要继续使用全局IdP并使其从组织设置中消失,请在配置中将模式设置为全局模式。", "idpGlobalModeBannerUpgradeRequired": "此服务器上禁用了每个组织的身份提供商(Idps)。它正在使用全局身份提供商(所有组织共享)。 在 管理面板管理全局身份。要使用每个组织的身份提供者,您必须升级到企业版本。", "idpGlobalModeBannerLicenseRequired": "此服务器上禁用了每个组织的身份提供商(Idps)。它正在使用全局身份提供商(所有组织共享)。 在 管理面板管理全局身份。要使用每个组织的身份提供者,需要企业许可证。", "idpDeletedDescription": "身份提供商删除成功", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "您确定要永久删除身份提供者吗?", "idpMessageRemove": "这将删除身份提供者和所有相关的配置。通过此提供者进行身份验证的用户将无法登录。", "idpMessageConfirm": "要确认,请在下面输入身份提供者的名称。", "idpConfirmDelete": "确认删除身份提供商", "idpDelete": "删除身份提供商", "idp": "身份提供商", "idpSearch": "搜索身份提供者...", "idpAdd": "添加身份提供商", "idpClientIdRequired": "客户端ID 是必需的。", "idpClientSecretRequired": "客户端密钥是必需的。", "idpErrorAuthUrlInvalid": "身份验证URL 必须是有效的 URL。", "idpErrorTokenUrlInvalid": "令牌URL 必须是有效的 URL。", "idpPathRequired": "标识符路径是必需的。", "idpScopeRequired": "授权范围是必需的。", "idpOidcDescription": "配置 OpenID 连接身份提供商", "idpCreatedDescription": "身份提供商创建成功", "idpCreate": "创建身份提供商", "idpCreateDescription": "配置用户身份验证的新身份提供商", "idpSeeAll": "查看所有身份提供商", "idpSettingsDescription": "配置身份提供者的基本信息", "idpDisplayName": "此身份提供商的显示名称", "idpAutoProvisionUsers": "自动提供用户", "idpAutoProvisionUsersDescription": "如果启用,用户将在首次登录时自动在系统中创建,并且能够映射用户到角色和组织。", "licenseBadge": "EE", "idpType": "提供者类型", "idpTypeDescription": "选择您想要配置的身份提供者类型", "idpOidcConfigure": "OAuth2/OIDC 配置", "idpOidcConfigureDescription": "配置 OAuth2/OIDC 供应商端点和凭据", "idpClientId": "客户端ID", "idpClientIdDescription": "来自身份提供商的 OAuth2 客户端 ID", "idpClientSecret": "客户端密钥", "idpClientSecretDescription": "来自身份提供商的 OAuth2 客户端密钥", "idpAuthUrl": "授权 URL", "idpAuthUrlDescription": "OAuth2 授权端点的 URL", "idpTokenUrl": "令牌 URL", "idpTokenUrlDescription": "OAuth2 令牌端点的 URL", "idpOidcConfigureAlert": "重要提示", "idpOidcConfigureAlertDescription": "在创建身份提供商后,您需要在身份提供商的设置中配置回调URL。 成功创建后将提供回调URL。", "idpToken": "令牌配置", "idpTokenDescription": "配置如何从 ID 令牌中提取用户信息", "idpJmespathAbout": "关于 JMESPath", "idpJmespathAboutDescription": "以下路径使用 JMESPath 语法从 ID 令牌中提取值。", "idpJmespathAboutDescriptionLink": "了解更多 JMESPath 信息", "idpJmespathLabel": "标识符路径", "idpJmespathLabelDescription": "ID 令牌中用户标识符的路径", "idpJmespathEmailPathOptional": "邮箱路径(可选)", "idpJmespathEmailPathOptionalDescription": "ID 令牌中用户邮箱的路径", "idpJmespathNamePathOptional": "用户名路径(可选)", "idpJmespathNamePathOptionalDescription": "ID 令牌中用户名的路径", "idpOidcConfigureScopes": "作用域(Scopes)", "idpOidcConfigureScopesDescription": "以空格分隔的 OAuth2 请求作用域列表", "idpSubmit": "创建身份提供商", "orgPolicies": "组织策略", "idpSettings": "{idpName} 设置", "idpCreateSettingsDescription": "配置身份提供商的设置", "roleMapping": "角色映射", "orgMapping": "组织映射", "orgPoliciesSearch": "搜索组织策略...", "orgPoliciesAdd": "添加组织策略", "orgRequired": "组织是必填项", "error": "错误", "success": "成功", "orgPolicyAddedDescription": "策略添加成功", "orgPolicyUpdatedDescription": "策略更新成功", "orgPolicyDeletedDescription": "已成功删除策略", "defaultMappingsUpdatedDescription": "默认映射更新成功", "orgPoliciesAbout": "关于组织政策", "orgPoliciesAboutDescription": "组织策略用于根据用户的 ID 令牌来控制对组织的访问。 您可以指定 JMESPath 表达式来提取角色和组织信息从 ID 令牌中提取信息。", "orgPoliciesAboutDescriptionLink": "欲了解更多信息,请参阅文件。", "defaultMappingsOptional": "默认映射(可选)", "defaultMappingsOptionalDescription": "当没有为某个组织定义组织的政策时,使用默认映射。 您可以指定默认角色和组织映射回到这里。", "defaultMappingsRole": "默认角色映射", "defaultMappingsRoleDescription": "此表达式的结果必须返回组织中定义的角色名称作为字符串。", "defaultMappingsOrg": "默认组织映射", "defaultMappingsOrgDescription": "此表达式必须返回 组织ID 或 true 才能允许用户访问组织。", "defaultMappingsSubmit": "保存默认映射", "orgPoliciesEdit": "编辑组织策略", "org": "组织", "orgSelect": "选择组织", "orgSearch": "搜索", "orgNotFound": "找不到组织。", "roleMappingPathOptional": "角色映射路径(可选)", "orgMappingPathOptional": "组织映射路径(可选)", "orgPolicyUpdate": "更新策略", "orgPolicyAdd": "添加策略", "orgPolicyConfig": "配置组织访问权限", "idpUpdatedDescription": "身份提供商更新成功", "redirectUrl": "重定向网址", "orgIdpRedirectUrls": "重定向URL", "redirectUrlAbout": "关于重定向网址", "redirectUrlAboutDescription": "这是用户在验证后将被重定向到的URL。您需要在身份提供者的设置中配置此URL。", "pangolinAuth": "认证 - Pangolin", "verificationCodeLengthRequirements": "您的验证码必须是8个字符。", "errorOccurred": "发生错误", "emailErrorVerify": "验证电子邮件失败:", "emailVerified": "电子邮件验证成功!重定向您...", "verificationCodeErrorResend": "无法重新发送验证码:", "verificationCodeResend": "验证码已重新发送", "verificationCodeResendDescription": "我们已将验证码重新发送到您的电子邮件地址。请检查您的收件箱。", "emailVerify": "验证电子邮件", "emailVerifyDescription": "输入验证码发送到您的电子邮件地址。", "verificationCode": "验证码", "verificationCodeEmailSent": "我们向您的电子邮件地址发送了验证码。", "submit": "提交", "emailVerifyResendProgress": "正在重新发送...", "emailVerifyResend": "没有收到代码?点击此处重新发送", "passwordNotMatch": "密码不匹配", "signupError": "注册时出错", "pangolinLogoAlt": "Pangolin 标志", "inviteAlready": "看起来您已被邀请!", "inviteAlreadyDescription": "要接受邀请,您必须登录或创建一个帐户。", "signupQuestion": "已经有一个帐户?", "login": "登录", "resourceNotFound": "找不到资源", "resourceNotFoundDescription": "您要访问的资源不存在。", "pincodeRequirementsLength": "PIN码必须是6位数字", "pincodeRequirementsChars": "PIN 必须只包含数字", "passwordRequirementsLength": "密码必须至少 1 个字符长", "passwordRequirementsTitle": "密码要求:", "passwordRequirementLength": "至少8个字符长", "passwordRequirementUppercase": "至少一个大写字母", "passwordRequirementLowercase": "至少一个小写字母", "passwordRequirementNumber": "至少一个数字", "passwordRequirementSpecial": "至少一个特殊字符", "passwordRequirementsMet": "✓ 密码满足所有要求", "passwordStrength": "密码强度", "passwordStrengthWeak": "弱", "passwordStrengthMedium": "中", "passwordStrengthStrong": "强", "passwordRequirements": "要求:", "passwordRequirementLengthText": "8+ 个字符", "passwordRequirementUppercaseText": "大写字母 (A-Z)", "passwordRequirementLowercaseText": "小写字母 (a-z)", "passwordRequirementNumberText": "数字 (0-9)", "passwordRequirementSpecialText": "特殊字符 (!@#$%...)", "passwordsDoNotMatch": "密码不匹配", "otpEmailRequirementsLength": "OTP 必须至少 1 个字符长", "otpEmailSent": "OTP 已发送", "otpEmailSentDescription": "OTP 已经发送到您的电子邮件", "otpEmailErrorAuthenticate": "通过电子邮件身份验证失败", "pincodeErrorAuthenticate": "Pincode 验证失败", "passwordErrorAuthenticate": "密码验证失败", "poweredBy": "支持者:", "authenticationRequired": "需要身份验证", "authenticationMethodChoose": "请选择您偏好的方式来访问 {name}", "authenticationRequest": "您必须通过身份验证才能访问 {name}", "user": "用户", "pincodeInput": "6位数字 PIN 码", "pincodeSubmit": "使用PIN登录", "passwordSubmit": "使用密码登录", "otpEmailDescription": "一次性代码将发送到此电子邮件。", "otpEmailSend": "发送一次性代码", "otpEmail": "一次性密码 (OTP)", "otpEmailSubmit": "提交 OTP", "backToEmail": "回到电子邮件", "noSupportKey": "服务器当前未使用支持者密钥,欢迎支持本项目!", "accessDenied": "访问被拒绝", "accessDeniedDescription": "当前账户无权访问此资源。如认为这是错误,请与管理员联系。", "accessTokenError": "检查访问令牌时出错", "accessGranted": "已授予访问", "accessUrlInvalid": "访问 URL 无效", "accessGrantedDescription": "您已获准访问此资源,正在为您跳转...", "accessUrlInvalidDescription": "此共享访问URL无效。请联系资源所有者获取新URL。", "tokenInvalid": "无效的令牌", "pincodeInvalid": "无效的代码", "passwordErrorRequestReset": "请求重置失败:", "passwordErrorReset": "重置密码失败:", "passwordResetSuccess": "密码重置成功!返回登录...", "passwordReset": "重置密码", "passwordResetDescription": "按照步骤重置您的密码", "passwordResetSent": "我们将发送一个验证码到这个电子邮件地址。", "passwordResetCode": "验证码", "passwordResetCodeDescription": "请检查您的电子邮件以获取验证码。", "generatePasswordResetCode": "生成密码重置代码", "passwordResetCodeGenerated": "密码重置代码已生成", "passwordResetCodeGeneratedDescription": "与用户分享此代码。他们可以用它来重置他们的密码。", "passwordResetUrl": "Reset URL", "passwordNew": "新密码", "passwordNewConfirm": "确认新密码", "changePassword": "更改密码", "changePasswordDescription": "更新您的帐户密码", "oldPassword": "当前密码", "newPassword": "新密码", "confirmNewPassword": "确认新密码", "changePasswordError": "更改密码失败", "changePasswordErrorDescription": "更改您的密码时出错", "changePasswordSuccess": "密码修改成功", "changePasswordSuccessDescription": "您的密码已成功更新", "passwordExpiryRequired": "需要密码过期", "passwordExpiryDescription": "该机构要求您每 {maxDays} 天更改一次密码。", "changePasswordNow": "现在更改密码", "pincodeAuth": "验证器代码", "pincodeSubmit2": "提交代码", "passwordResetSubmit": "请求重置", "passwordResetAlreadyHaveCode": "输入代码", "passwordResetSmtpRequired": "请联系您的管理员", "passwordResetSmtpRequiredDescription": "需要密码重置密码。请联系您的管理员寻求帮助。", "passwordBack": "回到密码", "loginBack": "返回主登录页面", "signup": "注册", "loginStart": "登录以开始", "idpOidcTokenValidating": "正在验证 OIDC 令牌", "idpOidcTokenResponse": "验证 OIDC 令牌响应", "idpErrorOidcTokenValidating": "验证 OIDC 令牌出错", "idpConnectingTo": "连接到{name}", "idpConnectingToDescription": "正在验证您的身份", "idpConnectingToProcess": "正在连接...", "idpConnectingToFinished": "已连接", "idpErrorConnectingTo": "无法连接到 {name},请联系管理员协助处理。", "idpErrorNotFound": "找不到 IdP", "inviteInvalid": "无效邀请", "inviteInvalidDescription": "邀请链接无效。", "inviteErrorWrongUser": "邀请不是该用户的", "inviteErrorUserNotExists": "用户不存在。请先创建帐户。", "inviteErrorLoginRequired": "您必须登录才能接受邀请", "inviteErrorExpired": "邀请可能已过期", "inviteErrorRevoked": "邀请可能已被吊销了", "inviteErrorTypo": "邀请链接中可能有一个类型", "pangolinSetup": "认证 - Pangolin", "orgNameRequired": "组织名称是必需的", "orgIdRequired": "组织ID是必需的", "orgIdMaxLength": "组织 ID 必须至少 32 个字符", "orgErrorCreate": "创建组织时出错", "pageNotFound": "找不到页面", "pageNotFoundDescription": "哎呀!您正在查找的页面不存在。", "overview": "概览", "home": "首页", "settings": "设置", "usersAll": "所有用户", "license": "许可协议", "pangolinDashboard": "仪表板 - Pangolin", "noResults": "未找到任何结果。", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "已输入的标签", "tagsEnteredDescription": "这些是您输入的标签。", "tagsWarnCannotBeLessThanZero": "最大标签和最小标签不能小于 0", "tagsWarnNotAllowedAutocompleteOptions": "标记不允许为每个自动完成选项", "tagsWarnInvalid": "无效的标签,每个有效标签", "tagWarnTooShort": "标签 {tagText} 太短", "tagWarnTooLong": "标签 {tagText} 太长", "tagsWarnReachedMaxNumber": "已达到允许标签的最大数量", "tagWarnDuplicate": "未添加重复标签 {tagText}", "supportKeyInvalid": "无效密钥", "supportKeyInvalidDescription": "您的支持者密钥无效。", "supportKeyValid": "有效的密钥", "supportKeyValidDescription": "您的支持者密钥已被验证。感谢您的支持!", "supportKeyErrorValidationDescription": "验证支持者密钥失败。", "supportKey": "支持开发和通过一个 Pangolin !", "supportKeyDescription": "购买支持者钥匙,帮助我们继续为社区发展 Pangolin 。 您的贡献使我们能够投入更多的时间来维护和添加所有人的新功能。 我们永远不会用这个来支付墙上的功能。这与任何商业版是分开的。", "supportKeyPet": "您还可以领养并见到属于自己的 Pangolin!", "supportKeyPurchase": "付款通过 GitHub 进行处理,之后您可以在以下位置获取您的密钥:", "supportKeyPurchaseLink": "我们的网站", "supportKeyPurchase2": "并在这里兑换。", "supportKeyLearnMore": "了解更多。", "supportKeyOptions": "请选择最适合您的选项。", "supportKetOptionFull": "完全支持者", "forWholeServer": "适用于整个服务器", "lifetimePurchase": "终身购买", "supporterStatus": "支持者状态", "buy": "购买", "supportKeyOptionLimited": "有限支持者", "forFiveUsers": "适用于 5 或更少用户", "supportKeyRedeem": "兑换支持者密钥", "supportKeyHideSevenDays": "隐藏7天", "supportKeyEnter": "输入支持者密钥", "supportKeyEnterDescription": "见到你自己的 Pangolin!", "githubUsername": "GitHub 用户名", "supportKeyInput": "支持者密钥", "supportKeyBuy": "购买支持者密钥", "logoutError": "注销错误", "signingAs": "登录为", "serverAdmin": "服务器管理员", "managedSelfhosted": "托管自托管", "otpEnable": "启用双因子认证", "otpDisable": "禁用双因子认证", "logout": "登出", "licenseTierProfessionalRequired": "需要专业版", "licenseTierProfessionalRequiredDescription": "此功能仅在专业版可用。", "actionGetOrg": "获取组织", "updateOrgUser": "更新组织用户", "createOrgUser": "创建组织用户", "actionUpdateOrg": "更新组织", "actionRemoveInvitation": "移除邀请", "actionUpdateUser": "更新用户", "actionGetUser": "获取用户", "actionGetOrgUser": "获取组织用户", "actionListOrgDomains": "列出组织域", "actionGetDomain": "获取域", "actionCreateOrgDomain": "创建域", "actionUpdateOrgDomain": "更新域", "actionDeleteOrgDomain": "删除域", "actionGetDNSRecords": "获取 DNS 记录", "actionRestartOrgDomain": "重新启动域", "actionCreateSite": "创建站点", "actionDeleteSite": "删除站点", "actionGetSite": "获取站点", "actionListSites": "站点列表", "actionApplyBlueprint": "应用蓝图", "actionListBlueprints": "列表蓝图", "actionGetBlueprint": "获取蓝图", "setupToken": "设置令牌", "setupTokenDescription": "从服务器控制台输入设置令牌。", "setupTokenRequired": "需要设置令牌", "actionUpdateSite": "更新站点", "actionListSiteRoles": "允许站点角色列表", "actionCreateResource": "创建资源", "actionDeleteResource": "删除资源", "actionGetResource": "获取资源", "actionListResource": "列出资源", "actionUpdateResource": "更新资源", "actionListResourceUsers": "列出资源用户", "actionSetResourceUsers": "设置资源用户", "actionSetAllowedResourceRoles": "设置允许的资源角色", "actionListAllowedResourceRoles": "列出允许的资源角色", "actionSetResourcePassword": "设置资源密码", "actionSetResourcePincode": "设置资源粉码", "actionSetResourceEmailWhitelist": "设置资源电子邮件白名单", "actionGetResourceEmailWhitelist": "获取资源电子邮件白名单", "actionCreateTarget": "创建目标", "actionDeleteTarget": "删除目标", "actionGetTarget": "获取目标", "actionListTargets": "列表目标", "actionUpdateTarget": "更新目标", "actionCreateRole": "创建角色", "actionDeleteRole": "删除角色", "actionGetRole": "获取角色", "actionListRole": "角色列表", "actionUpdateRole": "更新角色", "actionListAllowedRoleResources": "列表允许的角色资源", "actionInviteUser": "邀请用户", "actionRemoveUser": "删除用户", "actionListUsers": "列出用户", "actionAddUserRole": "添加用户角色", "actionGenerateAccessToken": "生成访问令牌", "actionDeleteAccessToken": "删除访问令牌", "actionListAccessTokens": "访问令牌", "actionCreateResourceRule": "创建资源规则", "actionDeleteResourceRule": "删除资源规则", "actionListResourceRules": "列出资源规则", "actionUpdateResourceRule": "更新资源规则", "actionListOrgs": "列出组织", "actionCheckOrgId": "检查组织ID", "actionCreateOrg": "创建组织", "actionDeleteOrg": "删除组织", "actionListApiKeys": "列出API密钥", "actionListApiKeyActions": "列出API密钥动作", "actionSetApiKeyActions": "设置 API 密钥允许的操作", "actionCreateApiKey": "创建 API 密钥", "actionDeleteApiKey": "删除 API 密钥", "actionCreateIdp": "创建IDP", "actionUpdateIdp": "更新IDP", "actionDeleteIdp": "删除IDP", "actionListIdps": "列出IDP", "actionGetIdp": "获取IDP", "actionCreateIdpOrg": "创建 IDP组织策略", "actionDeleteIdpOrg": "删除 IDP组织策略", "actionListIdpOrgs": "列出 IDP组织", "actionUpdateIdpOrg": "更新 IDP组织", "actionCreateClient": "创建客户端", "actionDeleteClient": "删除客户端", "actionArchiveClient": "归档客户端", "actionUnarchiveClient": "取消归档客户端", "actionBlockClient": "屏蔽客户端", "actionUnblockClient": "解除屏蔽客户端", "actionUpdateClient": "更新客户端", "actionListClients": "列出客户端", "actionGetClient": "获取客户端", "actionCreateSiteResource": "创建站点资源", "actionDeleteSiteResource": "删除站点资源", "actionGetSiteResource": "获取站点资源", "actionListSiteResources": "列出站点资源", "actionUpdateSiteResource": "更新站点资源", "actionListInvitations": "邀请列表", "actionExportLogs": "导出日志", "actionViewLogs": "查看日志", "noneSelected": "未选择", "orgNotFound2": "未找到组织。", "searchPlaceholder": "搜索...", "emptySearchOptions": "未找到选项", "create": "创建", "orgs": "组织", "loginError": "发生意外错误。请重试。", "loginRequiredForDevice": "您的设备需要登录。", "passwordForgot": "忘记密码?", "otpAuth": "两步验证", "otpAuthDescription": "从您的身份验证程序中输入代码或您的单次备份代码。", "otpAuthSubmit": "提交代码", "idpContinue": "或者继续", "otpAuthBack": "回到密码", "navbar": "导航菜单", "navbarDescription": "应用程序的主导航菜单", "navbarDocsLink": "文件", "otpErrorEnable": "无法启用 2FA", "otpErrorEnableDescription": "启用 2FA 时出错", "otpSetupCheckCode": "请输入您的6位数字代码", "otpSetupCheckCodeRetry": "无效的代码。请重试。", "otpSetup": "启用两步验证", "otpSetupDescription": "用额外的保护层来保护您的帐户", "otpSetupScanQr": "用您的身份验证程序扫描此二维码或手动输入密钥:", "otpSetupSecretCode": "验证器代码", "otpSetupSuccess": "启用两步验证", "otpSetupSuccessStoreBackupCodes": "您的帐户现在更加安全。不要忘记保存您的备份代码。", "otpErrorDisable": "无法禁用 2FA", "otpErrorDisableDescription": "禁用2FA 时出错", "otpRemove": "禁用两步验证", "otpRemoveDescription": "为您的帐户禁用两步验证", "otpRemoveSuccess": "双重身份验证已禁用", "otpRemoveSuccessMessage": "您的帐户已禁用双重身份验证。您可以随时再次启用它。", "otpRemoveSubmit": "禁用两步验证", "paginator": "第 {current} 页,共 {last} 页", "paginatorToFirst": "转到第一页", "paginatorToPrevious": "转到上一页", "paginatorToNext": "转到下一页", "paginatorToLast": "转到最后一页", "copyText": "复制文本", "copyTextFailed": "复制文本失败: ", "copyTextClipboard": "复制到剪贴板", "inviteErrorInvalidConfirmation": "无效确认", "passwordRequired": "必须填写密码", "allowAll": "允许所有", "permissionsAllowAll": "允许所有权限", "githubUsernameRequired": "必须填写 GitHub 用户名", "supportKeyRequired": "必须填写支持者密钥", "passwordRequirementsChars": "密码至少需要 8 个字符", "language": "语言", "verificationCodeRequired": "必须输入代码", "userErrorNoUpdate": "没有要更新的用户", "siteErrorNoUpdate": "没有要更新的站点", "resourceErrorNoUpdate": "没有可更新的资源", "authErrorNoUpdate": "没有要更新的身份验证信息", "orgErrorNoUpdate": "没有要更新的组织", "orgErrorNoProvided": "未提供组织", "apiKeysErrorNoUpdate": "没有要更新的 API 密钥", "sidebarOverview": "概览", "sidebarHome": "首页", "sidebarSites": "站点", "sidebarApprovals": "审批请求", "sidebarResources": "资源", "sidebarProxyResources": "公开的", "sidebarClientResources": "非公开的", "sidebarAccessControl": "访问控制", "sidebarLogsAndAnalytics": "日志与分析", "sidebarTeam": "团队", "sidebarUsers": "用户", "sidebarAdmin": "管理员", "sidebarInvitations": "邀请", "sidebarRoles": "角色", "sidebarShareableLinks": "链接", "sidebarApiKeys": "API密钥", "sidebarSettings": "设置", "sidebarAllUsers": "所有用户", "sidebarIdentityProviders": "身份提供商", "sidebarLicense": "证书", "sidebarClients": "客户端", "sidebarUserDevices": "用户设备", "sidebarMachineClients": "机", "sidebarDomains": "域", "sidebarGeneral": "管理", "sidebarLogAndAnalytics": "日志与分析", "sidebarBluePrints": "蓝图", "sidebarOrganization": "组织", "sidebarManagement": "管理", "sidebarBillingAndLicenses": "帐单和许可证", "sidebarLogsAnalytics": "分析", "blueprints": "蓝图", "blueprintsDescription": "应用声明配置并查看先前运行的", "blueprintAdd": "添加蓝图", "blueprintGoBack": "查看所有蓝图", "blueprintCreate": "创建蓝图", "blueprintCreateDescription2": "按照下面的步骤创建和应用新的蓝图", "blueprintDetails": "蓝图详细信息", "blueprintDetailsDescription": "查看应用蓝图的结果和发生的任何错误", "blueprintInfo": "蓝图信息", "message": "留言", "blueprintContentsDescription": "定义描述基础设施的 YAML 内容", "blueprintErrorCreateDescription": "应用蓝图时出错", "blueprintErrorCreate": "创建蓝图时出错", "searchBlueprintProgress": "搜索蓝图...", "appliedAt": "应用于", "source": "来源", "contents": "目录", "parsedContents": "解析内容 (只读)", "enableDockerSocket": "启用 Docker 蓝图", "enableDockerSocketDescription": "启用 Docker Socket 标签擦除蓝图标签。套接字路径必须提供给新的。", "viewDockerContainers": "查看停靠容器", "containersIn": "{siteName} 中的容器", "selectContainerDescription": "选择任何容器作为目标的主机名。点击端口使用端口。", "containerName": "名称", "containerImage": "图片", "containerState": "状态", "containerNetworks": "网络", "containerHostnameIp": "主机名/IP", "containerLabels": "标签", "containerLabelsCount": "{count, plural, other {# 标签}}", "containerLabelsTitle": "容器标签", "containerLabelEmpty": "<为空>", "containerPorts": "端口", "containerPortsMore": "+{count} 更多", "containerActions": "行动", "select": "选择", "noContainersMatchingFilters": "没有找到匹配当前过滤器的容器。", "showContainersWithoutPorts": "显示没有端口的容器", "showStoppedContainers": "显示已停止的容器", "noContainersFound": "未找到容器。请确保Docker容器正在运行。", "searchContainersPlaceholder": "在 {count} 个容器中搜索...", "searchResultsCount": "{count, plural, other {# 个结果}}", "filters": "筛选器", "filterOptions": "过滤器选项", "filterPorts": "端口", "filterStopped": "已停止", "clearAllFilters": "清除所有过滤器", "columns": "列", "toggleColumns": "切换列", "refreshContainersList": "刷新容器列表", "searching": "搜索中...", "noContainersFoundMatching": "未找到与 \"{filter}\" 匹配的容器。", "light": "浅色", "dark": "深色", "system": "系统", "theme": "主题", "subnetRequired": "子网是必填项", "initialSetupTitle": "初始服务器设置", "initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。", "createAdminAccount": "创建管理员帐户", "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", "certificateStatus": "证书状态", "loading": "加载中", "loadingAnalytics": "加载分析", "restart": "重启", "domains": "域", "domainsDescription": "创建和管理组织中可用的域", "domainsSearch": "搜索域...", "domainAdd": "添加域", "domainAddDescription": "注册一个新域名到组织", "domainCreate": "创建域", "domainCreatedDescription": "域创建成功", "domainDeletedDescription": "成功删除域", "domainQuestionRemove": "您确定要删除域名吗?", "domainMessageRemove": "一旦删除,域将不再与组织相关联。", "domainConfirmDelete": "确认删除域", "domainDelete": "删除域", "domain": "域", "selectDomainTypeNsName": "域委派(NS)", "selectDomainTypeNsDescription": "此域及其所有子域。当您希望控制整个域区域时使用此选项。", "selectDomainTypeCnameName": "单个域(CNAME)", "selectDomainTypeCnameDescription": "仅此特定域。用于单个子域或特定域条目。", "selectDomainTypeWildcardName": "通配符域", "selectDomainTypeWildcardDescription": "此域名及其子域名。", "domainDelegation": "单个域", "selectType": "选择一个类型", "actions": "操作", "refresh": "刷新", "refreshError": "刷新数据失败", "verified": "已验证", "pending": "待定", "pendingApproval": "等待批准", "sidebarBilling": "计费", "billing": "计费", "orgBillingDescription": "管理账单信息和订阅", "github": "GitHub", "pangolinHosted": "Pangolin 托管", "fossorial": "Fossorial", "completeAccountSetup": "完成账户设置", "completeAccountSetupDescription": "设置您的密码以开始", "accountSetupSent": "我们将发送账号设置代码到该电子邮件地址。", "accountSetupCode": "设置代码", "accountSetupCodeDescription": "请检查您的邮箱以获取设置代码。", "passwordCreate": "创建密码", "passwordCreateConfirm": "确认密码", "accountSetupSubmit": "发送设置代码", "completeSetup": "完成设置", "accountSetupSuccess": "账号设置完成!欢迎来到 Pangolin!", "documentation": "文档", "saveAllSettings": "保存所有设置", "saveResourceTargets": "保存目标", "saveResourceHttp": "保存代理设置", "saveProxyProtocol": "保存代理协议设置", "settingsUpdated": "设置已更新", "settingsUpdatedDescription": "设置更新成功", "settingsErrorUpdate": "设置更新失败", "settingsErrorUpdateDescription": "更新设置时发生错误", "sidebarCollapse": "折叠", "sidebarExpand": "展开", "productUpdateMoreInfo": "{noOfUpdates} 个更新", "productUpdateInfo": "{noOfUpdates} 个更新", "productUpdateWhatsNew": "新功能", "productUpdateTitle": "产品更新", "productUpdateEmpty": "无更新", "dismissAll": "关闭所有", "pangolinUpdateAvailable": "可用更新", "pangolinUpdateAvailableInfo": "版本 {version} 已准备就绪", "pangolinUpdateAvailableReleaseNotes": "查看发布说明", "newtUpdateAvailable": "更新可用", "newtUpdateAvailableInfo": "新版本的 Newt 已可用。请更新到最新版本以获得最佳体验。", "domainPickerEnterDomain": "域名", "domainPickerPlaceholder": "example.com", "domainPickerDescription": "输入资源的完整域名以查看可用选项。", "domainPickerDescriptionSaas": "输入完整域名、子域或名称以查看可用选项。", "domainPickerTabAll": "所有", "domainPickerTabOrganization": "组织", "domainPickerTabProvided": "提供的", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "检查可用性...", "domainPickerNoMatchingDomains": "未找到匹配的域。请尝试不同的域或检查组织的域设置。", "domainPickerOrganizationDomains": "组织域", "domainPickerProvidedDomains": "提供的域", "domainPickerSubdomain": "子域:{subdomain}", "domainPickerNamespace": "命名空间:{namespace}", "domainPickerShowMore": "显示更多", "regionSelectorTitle": "选择区域", "regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。", "regionSelectorPlaceholder": "选择一个区域", "regionSelectorComingSoon": "即将推出", "billingLoadingSubscription": "正在加载订阅...", "billingFreeTier": "免费层", "billingWarningOverLimit": "警告:您已超出一个或多个使用限制。在您修改订阅或调整使用情况之前,您的站点将无法连接。", "billingUsageLimitsOverview": "使用限制概览", "billingMonitorUsage": "监控您的使用情况以对比已配置的限制。如需提高限制请联系我们 support@pangolin.net。", "billingDataUsage": "数据使用情况", "billingSites": "站点", "billingUsers": "用户", "billingDomains": "域", "billingOrganizations": "球队", "billingRemoteExitNodes": "远程节点", "billingNoLimitConfigured": "未配置限制", "billingEstimatedPeriod": "估计结算周期", "billingIncludedUsage": "包含的使用量", "billingIncludedUsageDescription": "您当前订阅计划中包含的使用量", "billingFreeTierIncludedUsage": "免费层使用额度", "billingIncluded": "包含", "billingEstimatedTotal": "预计总额:", "billingNotes": "备注", "billingEstimateNote": "这是根据您当前使用情况的估算。", "billingActualChargesMayVary": "实际费用可能会有变化。", "billingBilledAtEnd": "您将在结算周期结束时被计费。", "billingModifySubscription": "修改订阅", "billingStartSubscription": "开始订阅", "billingRecurringCharge": "周期性收费", "billingManageSubscriptionSettings": "管理订阅设置和首选项", "billingNoActiveSubscription": "您没有活跃的订阅。开始订阅以增加使用限制。", "billingFailedToLoadSubscription": "无法加载订阅", "billingFailedToLoadUsage": "无法加载使用情况", "billingFailedToGetCheckoutUrl": "无法获取结账网址", "billingPleaseTryAgainLater": "请稍后再试。", "billingCheckoutError": "结账错误", "billingFailedToGetPortalUrl": "无法获取门户网址", "billingPortalError": "门户错误", "billingDataUsageInfo": "当连接到云端时,您将为通过安全隧道传输的所有数据收取费用。 这包括您所有站点的进出流量。 当您达到上限时,您的站点将断开连接,直到您升级计划或减少使用。使用节点时不收取数据。", "billingSInfo": "您可以使用多少站点", "billingUsersInfo": "您可以使用多少用户", "billingDomainInfo": "您可以使用多少域", "billingRemoteExitNodesInfo": "您可以使用多少远程节点", "billingLicenseKeys": "许可证密钥", "billingLicenseKeysDescription": "管理您的许可证密钥订阅", "billingLicenseSubscription": "许可订阅", "billingInactive": "未激活", "billingLicenseItem": "许可证项目", "billingQuantity": "数量", "billingTotal": "总计", "billingModifyLicenses": "修改许可订阅", "domainNotFound": "域未找到", "domainNotFoundDescription": "此资源已禁用,因为该域不再在我们的系统中存在。请为此资源设置一个新域。", "failed": "失败", "createNewOrgDescription": "创建一个新组织", "organization": "组织", "primary": "主要的", "port": "端口", "securityKeyManage": "管理安全密钥", "securityKeyDescription": "添加或删除用于无密码认证的安全密钥", "securityKeyRegister": "注册新的安全密钥", "securityKeyList": "您的安全密钥", "securityKeyNone": "尚未注册安全密钥", "securityKeyNameRequired": "名称为必填项", "securityKeyRemove": "删除", "securityKeyLastUsed": "上次使用:{date}", "securityKeyNameLabel": "名称", "securityKeyRegisterSuccess": "安全密钥注册成功", "securityKeyRegisterError": "注册安全密钥失败", "securityKeyRemoveSuccess": "安全密钥删除成功", "securityKeyRemoveError": "删除安全密钥失败", "securityKeyLoadError": "加载安全密钥失败", "securityKeyLogin": "使用安全密钥", "securityKeyAuthError": "使用安全密钥认证失败", "securityKeyRecommendation": "考虑在其他设备上注册另一个安全密钥,以确保不会被锁定在您的账户之外。", "registering": "注册中...", "securityKeyPrompt": "请使用您的安全密钥验证身份。确保您的安全密钥已连接并准备好。", "securityKeyBrowserNotSupported": "您的浏览器不支持安全密钥。请使用像 Chrome、Firefox 或 Safari 这样的现代浏览器。", "securityKeyPermissionDenied": "请允许访问您的安全密钥以继续登录。", "securityKeyRemovedTooQuickly": "请保持您的安全密钥连接,直到登录过程完成。", "securityKeyNotSupported": "您的安全密钥可能不兼容。请尝试不同的安全密钥。", "securityKeyUnknownError": "使用安全密钥时出现问题。请再试一次。", "twoFactorRequired": "注册安全密钥需要两步验证。", "twoFactor": "两步验证", "twoFactorAuthentication": "两步验证", "twoFactorDescription": "这个组织需要双重身份验证。", "enableTwoFactor": "启用两步验证", "organizationSecurityPolicy": "组织安全政策", "organizationSecurityPolicyDescription": "此机构拥有安全要求,您必须先满足才能访问", "securityRequirements": "安全要求", "allRequirementsMet": "已满足所有要求", "completeRequirementsToContinue": "完成下面的要求以继续访问此组织", "youCanNowAccessOrganization": "您现在可以访问此组织", "reauthenticationRequired": "会话长度", "reauthenticationDescription": "该机构要求您每 {maxDays} 天登录一次。", "reauthenticationDescriptionHours": "该机构要求您每 {maxHours} 小时登录一次。", "reauthenticateNow": "再次登录", "adminEnabled2FaOnYourAccount": "管理员已为{email}启用两步验证。请完成设置以继续。", "securityKeyAdd": "添加安全密钥", "securityKeyRegisterTitle": "注册新安全密钥", "securityKeyRegisterDescription": "连接您的安全密钥并输入名称以便识别", "securityKeyTwoFactorRequired": "要求两步验证", "securityKeyTwoFactorDescription": "请输入你的两步验证代码以注册安全密钥", "securityKeyTwoFactorRemoveDescription": "请输入你的两步验证代码以移除安全密钥", "securityKeyTwoFactorCode": "双因素代码", "securityKeyRemoveTitle": "移除安全密钥", "securityKeyRemoveDescription": "输入您的密码以移除安全密钥 \"{name}\"", "securityKeyNoKeysRegistered": "没有注册安全密钥", "securityKeyNoKeysDescription": "添加安全密钥以加强您的账户安全", "createDomainRequired": "必须输入域", "createDomainAddDnsRecords": "添加 DNS 记录", "createDomainAddDnsRecordsDescription": "将以下 DNS 记录添加到您的域名提供商以完成设置。", "createDomainNsRecords": "NS 记录", "createDomainRecord": "记录", "createDomainType": "类型:", "createDomainName": "名称:", "createDomainValue": "值:", "createDomainCnameRecords": "CNAME 记录", "createDomainARecords": "A记录", "createDomainRecordNumber": "记录 {number}", "createDomainTxtRecords": "TXT 记录", "createDomainSaveTheseRecords": "保存这些记录", "createDomainSaveTheseRecordsDescription": "务必保存这些 DNS 记录,因为您将无法再次查看它们。", "createDomainDnsPropagation": "DNS 传播", "createDomainDnsPropagationDescription": "DNS 更改可能需要一些时间才能在互联网上传播。这可能需要从几分钟到 48 小时,具体取决于您的 DNS 提供商和 TTL 设置。", "resourcePortRequired": "非 HTTP 资源必须输入端口号", "resourcePortNotAllowed": "HTTP 资源不应设置端口号", "billingPricingCalculatorLink": "价格计算器", "billingYourPlan": "您的计划", "billingViewOrModifyPlan": "查看或修改您当前的计划", "billingViewPlanDetails": "查看计划详细信息", "billingUsageAndLimits": "用法和限制", "billingViewUsageAndLimits": "查看您的计划限制和当前使用情况", "billingCurrentUsage": "当前使用情况", "billingMaximumLimits": "最大限制", "billingRemoteNodes": "远程节点", "billingUnlimited": "无限制", "billingPaidLicenseKeys": "付费许可证密钥", "billingManageLicenseSubscription": "管理您对付费的自托管许可证密钥的订阅", "billingCurrentKeys": "当前密钥", "billingModifyCurrentPlan": "修改当前计划", "billingConfirmUpgrade": "确认升级", "billingConfirmDowngrade": "确认降级", "billingConfirmUpgradeDescription": "您即将升级您的计划。请检查下面的新限额和定价。", "billingConfirmDowngradeDescription": "您即将降级计划。请检查下面的新限额和定价。", "billingPlanIncludes": "计划包含", "billingProcessing": "正在处理...", "billingConfirmUpgradeButton": "确认升级", "billingConfirmDowngradeButton": "确认降级", "billingLimitViolationWarning": "超出新计划限制", "billingLimitViolationDescription": "您当前的使用量超过了此计划的限制。降级后,所有操作都将被禁用,直到您在新的限制范围内减少使用量。 请查看以下当前超出限制的特性:", "billingFeatureLossWarning": "功能可用通知", "billingFeatureLossDescription": "如果降级,新计划中不可用的功能将被自动禁用。一些设置和配置可能会丢失。 请查看定价矩阵以了解哪些功能将不再可用。", "billingUsageExceedsLimit": "当前使用量 ({current}) 超出限制 ({limit})", "billingPastDueTitle": "过去到期的付款", "billingPastDueDescription": "您的付款已过期。请更新您的付款方法以继续使用您当前的计划功能。 如果不解决,您的订阅将被取消,您将被恢复到免费等级。", "billingUnpaidTitle": "订阅未付款", "billingUnpaidDescription": "您的订阅未付,您已恢复到免费等级。请更新您的付款方法以恢复您的订阅。", "billingIncompleteTitle": "付款不完成", "billingIncompleteDescription": "您的付款不完整。请完成付款过程以激活您的订阅。", "billingIncompleteExpiredTitle": "付款已过期", "billingIncompleteExpiredDescription": "您的付款尚未完成且已过期。您已恢复到免费级别。请再次订阅以恢复对已支付功能的访问。", "billingManageSubscription": "管理您的订阅", "billingResolvePaymentIssue": "请在升级或降级之前解决您的付款问题", "signUpTerms": { "IAgreeToThe": "我同意", "termsOfService": "服务条款", "and": "和", "privacyPolicy": "隐私政策。" }, "signUpMarketing": { "keepMeInTheLoop": "通过电子邮件让我在循环中保持新闻、更新和新功能。" }, "siteRequired": "需要站点。", "olmTunnel": "Olm 隧道", "olmTunnelDescription": "使用 Olm 进行客户端连接", "errorCreatingClient": "创建客户端出错", "clientDefaultsNotFound": "未找到客户端默认值", "createClient": "创建客户端", "createClientDescription": "创建一个新客户端来访问私有资源", "seeAllClients": "查看所有客户端", "clientInformation": "客户端信息", "clientNamePlaceholder": "客户端名称", "address": "地址", "subnetPlaceholder": "子网", "addressDescription": "客户的内部地址。必须属于组织的子网。", "selectSites": "选择站点", "sitesDescription": "客户端将与所选站点进行连接", "clientInstallOlm": "安装 Olm", "clientInstallOlmDescription": "在您的系统上运行 Olm", "clientOlmCredentials": "全权证书", "clientOlmCredentialsDescription": "这是客户端如何通过服务器进行身份验证", "olmEndpoint": "Endpoint", "olmId": "ID", "olmSecretKey": "密钥", "clientCredentialsSave": "保存证书", "clientCredentialsSaveDescription": "该信息仅会显示一次,请确保将其复制到安全位置。", "generalSettingsDescription": "配置此客户端的常规设置", "clientUpdated": "客户端已更新", "clientUpdatedDescription": "客户端已更新。", "clientUpdateFailed": "更新客户端失败", "clientUpdateError": "更新客户端时出错。", "sitesFetchFailed": "获取站点失败", "sitesFetchError": "获取站点时出错。", "olmErrorFetchReleases": "获取 Olm 发布版本时出错。", "olmErrorFetchLatest": "获取最新 Olm 发布版本时出错。", "enterCidrRange": "输入 CIDR 范围", "resourceEnableProxy": "启用公共代理", "resourceEnableProxyDescription": "启用到此资源的公共代理。这允许外部网络通过开放端口访问资源。需要 Traefik 配置。", "externalProxyEnabled": "外部代理已启用", "addNewTarget": "添加新目标", "targetsList": "目标列表", "advancedMode": "高级模式", "advancedSettings": "高级设置", "targetErrorDuplicateTargetFound": "找到重复的目标", "healthCheckHealthy": "正常", "healthCheckUnhealthy": "不正常", "healthCheckUnknown": "未知", "healthCheck": "健康检查", "configureHealthCheck": "配置健康检查", "configureHealthCheckDescription": "为 {target} 设置健康监控", "enableHealthChecks": "启用健康检查", "enableHealthChecksDescription": "监视此目标的健康状况。如果需要,您可以监视一个不同的终点。", "healthScheme": "方法", "healthSelectScheme": "选择方法", "healthCheckPortInvalid": "健康检查端口必须介于 1 到 65535 之间", "healthCheckPath": "路径", "healthHostname": "IP / 主机", "healthPort": "端口", "healthCheckPathDescription": "用于检查健康状态的路径。", "healthyIntervalSeconds": "健康间隔(秒)", "unhealthyIntervalSeconds": "不健康间隔 (秒)", "IntervalSeconds": "正常间隔", "timeoutSeconds": "超时(秒)", "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 被视为健康。", "customHeaders": "自定义标题", "customHeadersDescription": "头部新行分隔:头部名称:值", "headersValidationError": "头部必须是格式:头部名称:值。", "saveHealthCheck": "保存健康检查", "healthCheckSaved": "健康检查已保存", "healthCheckSavedDescription": "健康检查配置已成功保存。", "healthCheckError": "健康检查错误", "healthCheckErrorDescription": "保存健康检查配置时出错", "healthCheckPathRequired": "健康检查路径为必填项", "healthCheckMethodRequired": "HTTP 方法为必填项", "healthCheckIntervalMin": "检查间隔必须至少为 5 秒", "healthCheckTimeoutMin": "超时必须至少为 1 秒", "healthCheckRetryMin": "重试次数必须至少为 1 次", "httpMethod": "HTTP 方法", "selectHttpMethod": "选择 HTTP 方法", "domainPickerSubdomainLabel": "子域名", "domainPickerBaseDomainLabel": "根域名", "domainPickerSearchDomains": "搜索域名...", "domainPickerNoDomainsFound": "未找到域名", "domainPickerLoadingDomains": "加载域名...", "domainPickerSelectBaseDomain": "选择根域名...", "domainPickerNotAvailableForCname": "不适用于CNAME域", "domainPickerEnterSubdomainOrLeaveBlank": "输入子域名或留空以使用根域名。", "domainPickerEnterSubdomainToSearch": "输入一个子域名以搜索并从可用免费域名中选择。", "domainPickerFreeDomains": "免费域名", "domainPickerSearchForAvailableDomains": "搜索可用域名", "domainPickerNotWorkSelfHosted": "注意:自托管实例当前不提供免费的域名。", "resourceDomain": "域名", "resourceEditDomain": "编辑域名", "siteName": "站点名称", "proxyPort": "端口", "resourcesTableProxyResources": "公开的", "resourcesTableClientResources": "非公开的", "resourcesTableNoProxyResourcesFound": "未找到代理资源。", "resourcesTableNoInternalResourcesFound": "未找到内部资源。", "resourcesTableDestination": "目标", "resourcesTableAlias": "Alias", "resourcesTableAliasAddress": "别名地址", "resourcesTableAliasAddressInfo": "此地址是组织实用子网的一部分。它用来使用内部DNS解析来解析别名记录。", "resourcesTableClients": "客户端", "resourcesTableAndOnlyAccessibleInternally": "且仅在与客户端连接时可内部访问。", "resourcesTableNoTargets": "没有目标", "resourcesTableHealthy": "健康的", "resourcesTableDegraded": "降级", "resourcesTableOffline": "离线的", "resourcesTableUnknown": "未知的", "resourcesTableNotMonitored": "未监视的", "editInternalResourceDialogEditClientResource": "编辑私有资源", "editInternalResourceDialogUpdateResourceProperties": "更新{resourceName}的资源配置和访问控制。", "editInternalResourceDialogResourceProperties": "资源属性", "editInternalResourceDialogName": "名称", "editInternalResourceDialogProtocol": "协议", "editInternalResourceDialogSitePort": "站点端口", "editInternalResourceDialogTargetConfiguration": "目标配置", "editInternalResourceDialogCancel": "取消", "editInternalResourceDialogSaveResource": "保存资源", "editInternalResourceDialogSuccess": "成功", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "内部资源更新成功", "editInternalResourceDialogError": "错误", "editInternalResourceDialogFailedToUpdateInternalResource": "更新内部资源失败", "editInternalResourceDialogNameRequired": "名称为必填项", "editInternalResourceDialogNameMaxLength": "名称长度必须小于255个字符", "editInternalResourceDialogProxyPortMin": "代理端口必须至少为1", "editInternalResourceDialogProxyPortMax": "代理端口必须小于65536", "editInternalResourceDialogInvalidIPAddressFormat": "无效的IP地址格式", "editInternalResourceDialogDestinationPortMin": "目标端口必须至少为1", "editInternalResourceDialogDestinationPortMax": "目标端口必须小于65536", "editInternalResourceDialogPortModeRequired": "端口模式需要协议、代理端口和目的端口", "editInternalResourceDialogMode": "模式", "editInternalResourceDialogModePort": "端口", "editInternalResourceDialogModeHost": "主机", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "目标", "editInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。", "editInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP或主机名地址。", "editInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。", "createInternalResourceDialogNoSitesAvailable": "暂无可用站点", "createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一个子网的Newt站点来创建内部资源。", "createInternalResourceDialogClose": "关闭", "createInternalResourceDialogCreateClientResource": "创建私有资源", "createInternalResourceDialogCreateClientResourceDescription": "创建一个新资源只能为连接到组织的客户端访问", "createInternalResourceDialogResourceProperties": "资源属性", "createInternalResourceDialogName": "名称", "createInternalResourceDialogSite": "站点", "selectSite": "选择站点...", "noSitesFound": "未找到站点。", "createInternalResourceDialogProtocol": "协议", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "站点端口", "createInternalResourceDialogSitePortDescription": "使用此端口在连接到客户端时访问站点上的资源。", "createInternalResourceDialogTargetConfiguration": "目标配置", "createInternalResourceDialogDestinationIPDescription": "站点网络上资源的IP或主机名地址。", "createInternalResourceDialogDestinationPortDescription": "资源在目标IP上可访问的端口。", "createInternalResourceDialogCancel": "取消", "createInternalResourceDialogCreateResource": "创建资源", "createInternalResourceDialogSuccess": "成功", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "内部资源创建成功", "createInternalResourceDialogError": "错误", "createInternalResourceDialogFailedToCreateInternalResource": "创建内部资源失败", "createInternalResourceDialogNameRequired": "名称为必填项", "createInternalResourceDialogNameMaxLength": "名称长度必须小于255个字符", "createInternalResourceDialogPleaseSelectSite": "请选择一个站点", "createInternalResourceDialogProxyPortMin": "代理端口必须至少为1", "createInternalResourceDialogProxyPortMax": "代理端口必须小于65536", "createInternalResourceDialogInvalidIPAddressFormat": "无效的IP地址格式", "createInternalResourceDialogDestinationPortMin": "目标端口必须至少为1", "createInternalResourceDialogDestinationPortMax": "目标端口必须小于65536", "createInternalResourceDialogPortModeRequired": "端口模式需要协议、代理端口和目的端口", "createInternalResourceDialogMode": "模式", "createInternalResourceDialogModePort": "端口", "createInternalResourceDialogModeHost": "主机", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "目标", "createInternalResourceDialogDestinationHostDescription": "站点网络上资源的 IP 地址或主机名。", "createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。", "siteConfiguration": "配置", "siteAcceptClientConnections": "接受客户端连接", "siteAcceptClientConnectionsDescription": "允许用户设备和客户端访问此站点上的资源。这可以稍后更改。", "siteAddress": "站点地址 (高级)", "siteAddressDescription": "站点的内部地址。必须属于组织的子网。", "siteNameDescription": "可以稍后更改的站点显示名称。", "autoLoginExternalIdp": "自动使用外部IDP登录", "autoLoginExternalIdpDescription": "立即重定向用户到外部身份提供商进行身份验证。", "selectIdp": "选择IDP", "selectIdpPlaceholder": "选择一个IDP...", "selectIdpRequired": "在启用自动登录时,请选择一个IDP。", "autoLoginTitle": "重定向中", "autoLoginDescription": "正在将您重定向到外部身份提供商进行身份验证。", "autoLoginProcessing": "准备身份验证...", "autoLoginRedirecting": "重定向到登录...", "autoLoginError": "自动登录错误", "autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。", "autoLoginErrorGeneratingUrl": "生成身份验证URL失败。", "remoteExitNodeManageRemoteExitNodes": "远程节点", "remoteExitNodeDescription": "自托管您的远程中继和代理服务器节点", "remoteExitNodes": "节点", "searchRemoteExitNodes": "搜索节点...", "remoteExitNodeAdd": "添加节点", "remoteExitNodeErrorDelete": "删除节点时出错", "remoteExitNodeQuestionRemove": "您确定要从组织中删除该节点吗?", "remoteExitNodeMessageRemove": "一旦删除,该节点将不再能够访问。", "remoteExitNodeConfirmDelete": "确认删除节点", "remoteExitNodeDelete": "删除节点", "sidebarRemoteExitNodes": "远程节点", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "密钥", "remoteExitNodeCreate": { "title": "创建远程节点", "description": "创建一个新的自托管远程中继和代理服务器节点", "viewAllButton": "查看所有节点", "strategy": { "title": "创建策略", "description": "选择您想如何创建远程节点", "adopt": { "title": "采纳节点", "description": "如果您已经拥有该节点的凭据,请选择此项。" }, "generate": { "title": "生成密钥", "description": "如果您想为节点生成新密钥,请选择此选项." } }, "adopt": { "title": "采纳现有节点", "description": "输入您想要采用的现有节点的凭据", "nodeIdLabel": "节点 ID", "nodeIdDescription": "您想要采用的现有节点的 ID", "secretLabel": "密钥", "secretDescription": "现有节点的秘密密钥", "submitButton": "采用节点" }, "generate": { "title": "生成的凭据", "description": "使用这些生成的凭据来配置节点", "nodeIdTitle": "节点 ID", "secretTitle": "密钥", "saveCredentialsTitle": "将凭据添加到配置中", "saveCredentialsDescription": "将这些凭据添加到您的自托管 Pangolin 节点配置文件中以完成连接。", "submitButton": "创建节点" }, "validation": { "adoptRequired": "在通过现有节点时需要节点ID和密钥" }, "errors": { "loadDefaultsFailed": "无法加载默认值", "defaultsNotLoaded": "默认值未加载", "createFailed": "创建节点失败" }, "success": { "created": "节点创建成功" } }, "remoteExitNodeSelection": "节点选择", "remoteExitNodeSelectionDescription": "为此本地站点选择要路由流量的节点", "remoteExitNodeRequired": "必须为本地站点选择节点", "noRemoteExitNodesAvailable": "无可用节点", "noRemoteExitNodesAvailableDescription": "此组织没有可用的节点。首先创建一个节点来使用本地站点。", "exitNode": "出口节点", "country": "国家", "rulesMatchCountry": "当前基于源 IP", "managedSelfHosted": { "title": "托管自托管", "description": "更可靠和低维护自我托管的 Pangolin 服务器,带有额外的铃声和告密器", "introTitle": "托管自托管的潘戈林公司", "introDescription": "这是一种部署选择,为那些希望简洁和额外可靠的人设计,同时仍然保持他们的数据的私密性和自我托管性。", "introDetail": "通过此选项,您仍然运行您自己的 Pangolin 节点 — — 您的隧道、SSL 终止,并且流量在您的服务器上保持所有状态。 不同之处在于,管理和监测是通过我们的云层仪表板进行的,该仪表板开启了一些好处:", "benefitSimplerOperations": { "title": "简单的操作", "description": "无需运行您自己的邮件服务器或设置复杂的警报。您将从方框中获得健康检查和下限提醒。" }, "benefitAutomaticUpdates": { "title": "自动更新", "description": "云仪表盘快速演化,所以您可以获得新的功能和错误修复,而不必每次手动拉取新的容器。" }, "benefitLessMaintenance": { "title": "减少维护时间", "description": "没有要管理的数据库迁移、备份或额外的基础设施。我们在云端处理这个问题。" }, "benefitCloudFailover": { "title": "云失败", "description": "如果您的节点被关闭,您的隧道可能暂时无法连接到我们的云端,直到您将其重新连接上线。" }, "benefitHighAvailability": { "title": "高可用率(PoPs)", "description": "您还可以将多个节点添加到您的帐户中以获取冗余和更好的性能。" }, "benefitFutureEnhancements": { "title": "将来的改进", "description": "我们正在计划添加更多的分析、警报和管理工具,使你的部署更加有力。" }, "docsAlert": { "text": "在我们中更多地了解管理下的自托管选项", "documentation": "文档" }, "convertButton": "将此节点转换为管理自托管的" }, "internationaldomaindetected": "检测到国际域", "willbestoredas": "储存为:", "roleMappingDescription": "确定当用户启用自动配送时如何分配他们的角色。", "selectRole": "选择角色", "roleMappingExpression": "表达式", "selectRolePlaceholder": "选择角色", "selectRoleDescription": "选择一个角色,从此身份提供商分配给所有用户", "roleMappingExpressionDescription": "输入一个 JMESPath 表达式来从 ID 令牌提取角色信息", "idpTenantIdRequired": "租户ID是必需的", "invalidValue": "无效的值", "idpTypeLabel": "身份提供者类型", "roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'", "idpGoogleConfiguration": "Google 配置", "idpGoogleConfigurationDescription": "配置 Google OAuth2 凭据", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", "idpGoogleClientSecretDescription": "Google OAuth2 客户端密钥", "idpAzureConfiguration": "Azure Entra ID 配置", "idpAzureConfigurationDescription": "配置 Azure Entra ID OAuth2 凭据", "idpTenantId": "租户 ID", "idpTenantIdPlaceholder": "tenant-id", "idpAzureTenantIdDescription": "Azure 租户ID (在 Azure Active Directory 概览中找到)", "idpAzureClientIdDescription": "Azure 应用注册客户端 ID", "idpAzureClientSecretDescription": "Azure 应用程序注册客户端密钥", "idpGoogleTitle": "谷歌", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Google 配置", "idpAzureConfigurationTitle": "Azure Entra ID 配置", "idpTenantIdLabel": "租户 ID", "idpAzureClientIdDescription2": "Azure 应用注册客户端 ID", "idpAzureClientSecretDescription2": "Azure 应用程序注册客户端密钥", "idpGoogleDescription": "Google OAuth2/OIDC 提供商", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "子网", "subnetDescription": "此组织网络配置的子网。", "customDomain": "自定义域", "authPage": "身份验证页面", "authPageDescription": "为组织的身份验证页面设置自定义域", "authPageDomain": "认证页面域", "authPageBranding": "自定义品牌", "authPageBrandingDescription": "配置此组织身份验证页面的品牌", "authPageBrandingUpdated": "授权页面品牌更新成功", "authPageBrandingRemoved": "成功移除授权页面品牌", "authPageBrandingRemoveTitle": "移除授权页面品牌", "authPageBrandingQuestionRemove": "您确定要移除授权页面的品牌吗?", "authPageBrandingDeleteConfirm": "确认删除品牌", "brandingLogoURL": "Logo URL", "brandingLogoURLOrPath": "徽标URL或路径", "brandingLogoPathDescription": "输入网址或本地路径。", "brandingLogoURLDescription": "请在您的徽标图片中输入一个可公开访问的 URL。", "brandingPrimaryColor": "主要颜色", "brandingLogoWidth": "宽度(px)", "brandingLogoHeight": "高度(px)", "brandingOrgTitle": "组织授权页面标题", "brandingOrgDescription": "{orgName}将替换为组织名称", "brandingOrgSubtitle": "组织授权页面副标题", "brandingResourceTitle": "资源授权页面标题", "brandingResourceSubtitle": "资源授权页面副标题", "brandingResourceDescription": "{resourceName} 将替换为组织名称", "saveAuthPageDomain": "保存域", "saveAuthPageBranding": "保存品牌", "removeAuthPageBranding": "移除品牌", "noDomainSet": "没有域设置", "changeDomain": "更改域", "selectDomain": "选择域", "restartCertificate": "重新启动证书", "editAuthPageDomain": "编辑认证页面域", "setAuthPageDomain": "设置认证页面域", "failedToFetchCertificate": "获取证书失败", "failedToRestartCertificate": "重新启动证书失败", "addDomainToEnableCustomAuthPages": "用户将能够使用该域访问组织的登录页面并完成资源身份验证。", "selectDomainForOrgAuthPage": "选择组织认证页面的域", "domainPickerProvidedDomain": "提供的域", "domainPickerFreeProvidedDomain": "免费提供的域", "domainPickerVerified": "已验证", "domainPickerUnverified": "未验证", "domainPickerInvalidSubdomainStructure": "此子域包含无效的字符或结构。当您保存时,它将被自动清除。", "domainPickerError": "错误", "domainPickerErrorLoadDomains": "加载组织域名失败", "domainPickerErrorCheckAvailability": "检查域可用性失败", "domainPickerInvalidSubdomain": "无效的子域", "domainPickerInvalidSubdomainRemoved": "输入 \"{sub}\" 已被移除,因为其无效。", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 无法为 {domain} 变为有效。", "domainPickerSubdomainSanitized": "子域已净化", "domainPickerSubdomainCorrected": "\"{sub}\" 已被更正为 \"{sanitized}\"", "orgAuthSignInTitle": "组织登录", "orgAuthChooseIdpDescription": "选择您的身份提供商以继续", "orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。", "orgAuthSignInWithPangolin": "使用 Pangolin 登录", "orgAuthSignInToOrg": "登录到组织", "orgAuthSelectOrgTitle": "组织登录", "orgAuthSelectOrgDescription": "输入您的组织ID以继续", "orgAuthOrgIdPlaceholder": "您的组织", "orgAuthOrgIdHelp": "输入您组织的唯一标识符", "orgAuthSelectOrgHelp": "输入您的组织ID后,您将跳转到组织的登录页面,您可以使用SSO或组织凭据。", "orgAuthRememberOrgId": "记住这个组织ID", "orgAuthBackToSignIn": "返回标准登录", "orgAuthNoAccount": "没有账户?", "subscriptionRequiredToUse": "需要订阅才能使用此功能。", "mustUpgradeToUse": "您必须升级您的订阅才能使用此功能。", "subscriptionRequiredTierToUse": "此功能需要 {tier} 或更高级别。", "upgradeToTierToUse": "升级到 {tier} 或更高级别以使用此功能。", "subscriptionTierTier1": "首页", "subscriptionTierTier2": "团队", "subscriptionTierTier3": "业务", "subscriptionTierEnterprise": "企业", "idpDisabled": "身份提供者已禁用。", "orgAuthPageDisabled": "组织认证页面已禁用。", "domainRestartedDescription": "域验证重新启动成功", "resourceAddEntrypointsEditFile": "编辑文件:config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "编辑文件:docker-compose.yml", "emailVerificationRequired": "需要电子邮件验证。 请通过 {dashboardUrl}/auth/login 再次登录以完成此步骤。 然后,回到这里。", "twoFactorSetupRequired": "需要设置双因素身份验证。 请通过 {dashboardUrl}/auth/login 再次登录以完成此步骤。 然后,回到这里。", "additionalSecurityRequired": "需要额外的安全", "organizationRequiresAdditionalSteps": "这个组织需要额外的安全步骤才能访问资源。", "completeTheseSteps": "完成这些步骤", "enableTwoFactorAuthentication": "启用两步验证", "completeSecuritySteps": "完成安全步骤", "securitySettings": "安全设置", "dangerSection": "危险区域", "dangerSectionDescription": "永久删除与此组织相关的所有数据", "securitySettingsDescription": "配置组织安全策略", "requireTwoFactorForAllUsers": "所有用户需要两步验证", "requireTwoFactorDescription": "如果启用,此组织的所有内部用户必须启用双重身份验证才能访问组织。", "requireTwoFactorDisabledDescription": "此功能需要有效的许可证(企业)或活动订阅(SaS)", "requireTwoFactorCannotEnableDescription": "您必须为您的帐户启用双重身份验证才能对所有用户", "maxSessionLength": "最大会话长度", "maxSessionLengthDescription": "设置用户会话的最长时间。此后用户需要重新验证。", "maxSessionLengthDisabledDescription": "此功能需要有效的许可证(企业)或活动订阅(SaS)", "selectSessionLength": "选择会话长度", "unenforced": "未执行", "1Hour": "1 小时", "3Hours": "3 小时", "6Hours": "6 小时", "12Hours": "12 小时", "1DaySession": "1天", "3Days": "3 天", "7Days": "7 天", "14Days": "14 天", "30DaysSession": "30 天", "90DaysSession": "90 天", "180DaysSession": "180天", "passwordExpiryDays": "密码过期", "editPasswordExpiryDescription": "设置用户需要更改密码之前的天数。", "selectPasswordExpiry": "选择密码过期", "30Days": "30 天", "1Day": "1天", "60Days": "60天", "90Days": "90 天", "180Days": "180天", "1Year": "1 年", "subscriptionBadge": "需要订阅", "securityPolicyChangeWarning": "安全政策更改警告", "securityPolicyChangeDescription": "您即将更改安全政策设置。保存后,您可能需要重新认证以遵守这些政策更新。 所有不符合要求的用户也需要重新认证。", "securityPolicyChangeConfirmMessage": "我确认", "securityPolicyChangeWarningText": "这将影响组织中的所有用户", "authPageErrorUpdateMessage": "更新身份验证页面设置时出错", "authPageErrorUpdate": "无法更新认证页面", "authPageDomainUpdated": "授权页面域更新成功", "healthCheckNotAvailable": "本地的", "rewritePath": "重写路径", "rewritePathDescription": "在转发到目标之前,可以选择重写路径。", "continueToApplication": "继续应用", "checkingInvite": "正在检查邀请", "setResourceHeaderAuth": "设置 ResourceHeaderAuth", "resourceHeaderAuthRemove": "删除头部认证", "resourceHeaderAuthRemoveDescription": "已成功删除头部身份验证。", "resourceErrorHeaderAuthRemove": "删除头部身份验证失败", "resourceErrorHeaderAuthRemoveDescription": "无法删除资源的头部身份验证。", "resourceHeaderAuthProtectionEnabled": "头部认证已启用", "resourceHeaderAuthProtectionDisabled": "头部身份验证已禁用", "headerAuthRemove": "删除头部认证", "headerAuthAdd": "添加页眉认证", "resourceErrorHeaderAuthSetup": "设置页眉认证失败", "resourceErrorHeaderAuthSetupDescription": "无法设置资源的头部身份验证。", "resourceHeaderAuthSetup": "头部认证设置成功", "resourceHeaderAuthSetupDescription": "头部认证已成功设置。", "resourceHeaderAuthSetupTitle": "设置头部身份验证", "resourceHeaderAuthSetupTitleDescription": "使用HTTP 头身份验证来设置基本身份验证信息(用户名和密码)。使用 https://username:password@resource.example.com 访问它", "resourceHeaderAuthSubmit": "设置头部身份验证", "actionSetResourceHeaderAuth": "设置头部身份验证", "enterpriseEdition": "企业版", "unlicensed": "未授权", "beta": "测试版", "manageUserDevices": "用户设备", "manageUserDevicesDescription": "查看和管理用户用来私下连接到资源的设备", "downloadClientBannerTitle": "下载Pangolin客户端", "downloadClientBannerDescription": "下载适用于您系统的Pangolin客户端以连接到Pangolin网络并私下访问资源。", "manageMachineClients": "管理机器客户端", "manageMachineClientsDescription": "创建和管理服务器和系统用于私密连接到资源的客户端", "machineClientsBannerTitle": "服务器与自动化系统", "machineClientsBannerDescription": "机器客户端适用于不与特定用户关联的服务器与自动化系统。它们使用ID和密钥进行身份验证,并可以与Pangolin CLI、Olm CLI或作为容器运行。", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Olm 容器", "clientsTableUserClients": "用户", "clientsTableMachineClients": "机", "licenseTableValidUntil": "有效期至", "saasLicenseKeysSettingsTitle": "企业许可证", "saasLicenseKeysSettingsDescription": "为自我托管的 Pangolin 实例生成和管理企业许可证密钥", "sidebarEnterpriseLicenses": "许可协议", "generateLicenseKey": "生成许可证密钥", "generateLicenseKeyForm": { "validation": { "emailRequired": "请输入一个有效的电子邮件地址", "useCaseTypeRequired": "请选择一个使用的案例类型", "firstNameRequired": "必填名", "lastNameRequired": "姓氏是必填项", "primaryUseRequired": "请描述您的主要使用", "jobTitleRequiredBusiness": "企业使用必须有职位头衔。", "industryRequiredBusiness": "商业使用需要工业", "stateProvinceRegionRequired": "州/省/地区是必填项", "postalZipCodeRequired": "邮政编码是必需的", "companyNameRequiredBusiness": "企业使用需要公司名称", "countryOfResidenceRequiredBusiness": "商业使用必须是居住国", "countryRequiredPersonal": "国家需要个人使用", "agreeToTermsRequired": "您必须同意条款", "complianceConfirmationRequired": "您必须确认遵守Fossorial Commercial License" }, "useCaseOptions": { "personal": { "title": "个人使用", "description": "个人非商业用途,如学习、个人项目或实验。" }, "business": { "title": "商业使用", "description": "供组织、公司或商业或创收活动使用。" } }, "steps": { "emailLicenseType": { "title": "电子邮件和许可证类型", "description": "输入您的电子邮件并选择您的许可证类型" }, "personalInformation": { "title": "个人信息", "description": "告诉我们自己的信息" }, "contactInformation": { "title": "联系信息", "description": "您的联系信息" }, "termsGenerate": { "title": "条款并生成", "description": "审阅并接受条款生成您的许可证" } }, "alerts": { "commercialUseDisclosure": { "title": "使用情况披露", "description": "选择能准确反映您预定用途的许可等级。 个人许可证允许对个人、非商业性或小型商业活动免费使用软件,年收入毛额不到100 000美元。 超出这些限度的任何用途,包括在企业、组织内的用途。 或其他创收环境——需要有效的企业许可证和支付适用的许可证费用。 所有用户,不论是个人还是企业,都必须遵守寄养商业许可证条款。" }, "trialPeriodInformation": { "title": "试用期信息", "description": "此许可证密钥使企业特性能够持续7天的评价。 在评估期过后继续访问付费功能需要在有效的个人或企业许可证下激活。对于企业许可证,请联系Sales@pangolin.net。" } }, "form": { "useCaseQuestion": "您是否正在使用 Pangolin 进行个人或商业使用?", "firstName": "名字", "lastName": "名字", "jobTitle": "工作头衔:", "primaryUseQuestion": "您主要计划使用 Pangolin 吗?", "industryQuestion": "您的行业是什么?", "prospectiveUsersQuestion": "您期望有多少预期用户?", "prospectiveSitesQuestion": "您期望有多少站点(隧道)?", "companyName": "公司名称", "countryOfResidence": "居住国", "stateProvinceRegion": "州/省/地区", "postalZipCode": "邮政编码", "companyWebsite": "公司网站", "companyPhoneNumber": "公司电话号码", "country": "国家", "phoneNumberOptional": "电话号码 (可选)", "complianceConfirmation": "我确认我提供的资料是准确的,我遵守了寄养商业许可证。 报告不准确的信息或错误的产品使用是违反许可证的行为,可能导致您的密钥被撤销。" }, "buttons": { "close": "关闭", "previous": "上一个", "next": "下一个", "generateLicenseKey": "生成许可证密钥" }, "toasts": { "success": { "title": "许可证密钥生成成功", "description": "您的许可证密钥已经生成并准备使用。" }, "error": { "title": "生成许可证密钥失败", "description": "生成许可证密钥时出错。" } } }, "newPricingLicenseForm": { "title": "获取许可证", "description": "选择一个计划,告诉我们你计划如何使用 Pangolin。", "chooseTier": "选择您的计划", "viewPricingLink": "查看价格、特征和限制", "tiers": { "starter": { "title": "启动器", "description": "企业特征,25个用户,25个站点和社区支持。" }, "scale": { "title": "缩放比例", "description": "企业特征、50个用户、50个站点和优先支持。" } }, "personalUseOnly": "仅供个人使用 (免费许可证-无签出)", "buttons": { "continueToCheckout": "继续签出" }, "toasts": { "checkoutError": { "title": "签出错误", "description": "无法启动结帐。请重试。" } } }, "priority": "优先权", "priorityDescription": "先评估更高优先级线路。优先级 = 100意味着自动排序(系统决定). 使用另一个数字强制执行手动优先级。", "instanceName": "实例名称", "pathMatchModalTitle": "配置路径匹配", "pathMatchModalDescription": "根据传入请求的路径设置匹配方式。", "pathMatchType": "匹配类型", "pathMatchPrefix": "前缀", "pathMatchExact": "精准的", "pathMatchRegex": "正则表达式", "pathMatchValue": "路径值", "clear": "清空", "saveChanges": "保存更改", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/路径", "pathMatchPrefixHelp": "示例: /api 匹配/api, /api/users 等。", "pathMatchExactHelp": "示例:/api 匹配仅限/api", "pathMatchRegexHelp": "例如:^/api/.* 匹配/api/why", "pathRewriteModalTitle": "配置路径重写", "pathRewriteModalDescription": "在转发到目标之前变换匹配的路径。", "pathRewriteType": "重写类型", "pathRewritePrefixOption": "前缀 - 替换前缀", "pathRewriteExactOption": "精确-替换整个路径", "pathRewriteRegexOption": "正则表达式 - 替换模式", "pathRewriteStripPrefixOption": "删除前缀 - 删除前缀", "pathRewriteValue": "重写值", "pathRewriteRegexPlaceholder": "/new/$1", "pathRewriteDefaultPlaceholder": "/new-path", "pathRewritePrefixHelp": "用此值替换匹配的前缀", "pathRewriteExactHelp": "当路径匹配时用此值替换整个路径", "pathRewriteRegexHelp": "使用抓取组,如$1,$2来替换", "pathRewriteStripPrefixHelp": "留空以脱离前缀或提供新的前缀", "pathRewritePrefix": "前缀", "pathRewriteExact": "精准的", "pathRewriteRegex": "正则表达式", "pathRewriteStrip": "带状图", "pathRewriteStripLabel": "条形图", "sidebarEnableEnterpriseLicense": "启用企业许可证", "cannotbeUndone": "无法撤消。", "toConfirm": "确认.", "deleteClientQuestion": "您确定要从站点和组织中删除客户吗?", "clientMessageRemove": "一旦删除,客户端将无法连接到站点。", "sidebarLogs": "日志", "request": "请求", "requests": "请求", "logs": "日志", "logsSettingsDescription": "监控从此组织收集的日志", "searchLogs": "搜索日志...", "action": "行 动", "actor": "执行者", "timestamp": "时间戳", "accessLogs": "访问日志", "exportCsv": "导出CSV", "exportError": "导出CSV时发生未知错误", "exportCsvTooltip": "在时间范围内", "actorId": "执行者ID", "allowedByRule": "根据规则允许", "allowedNoAuth": "无认证", "validAccessToken": "有效访问令牌", "validHeaderAuth": "Valid header auth", "validPincode": "Valid Pincode", "validPassword": "有效密码", "validEmail": "Valid email", "validSSO": "Valid SSO", "resourceBlocked": "资源被阻止", "droppedByRule": "被规则删除", "noSessions": "无会话", "temporaryRequestToken": "临时请求令牌", "noMoreAuthMethods": "No Valid Auth", "ip": "IP", "reason": "原因", "requestLogs": "请求日志", "requestAnalytics": "请求分析", "host": "主机", "location": "地点", "actionLogs": "操作日志", "sidebarLogsRequest": "请求日志", "sidebarLogsAccess": "访问日志", "sidebarLogsAction": "操作日志", "logRetention": "日志保留", "logRetentionDescription": "管理不同类型的日志为这个机构保留多长时间或禁用这些日志", "requestLogsDescription": "查看此机构资源的详细请求日志", "requestAnalyticsDescription": "查看此机构资源的详细请求分析", "logRetentionRequestLabel": "请求日志保留", "logRetentionRequestDescription": "保留请求日志的时间", "logRetentionAccessLabel": "访问日志保留", "logRetentionAccessDescription": "保留访问日志的时间", "logRetentionActionLabel": "动作日志保留", "logRetentionActionDescription": "保留操作日志的时间", "logRetentionDisabled": "已禁用", "logRetention3Days": "3 天", "logRetention7Days": "7 天", "logRetention14Days": "14 天", "logRetention30Days": "30 天", "logRetention90Days": "90 天", "logRetentionForever": "永远的", "logRetentionEndOfFollowingYear": "下一年结束", "actionLogsDescription": "查看此机构执行的操作历史", "accessLogsDescription": "查看此机构资源的访问认证请求", "licenseRequiredToUse": "需要 Enterprise Edition 许可才能使用此功能。此功能也可在 Pangolin Cloud 中使用。", "ossEnterpriseEditionRequired": "Enterprise Edition 需要使用此功能。此功能也可在 Pangolin Cloud 中使用。", "certResolver": "证书解决器", "certResolverDescription": "选择用于此资源的证书解析器。", "selectCertResolver": "选择证书解析", "enterCustomResolver": "输入自定义解析器", "preferWildcardCert": "喜欢通配符证书", "unverified": "未验证", "domainSetting": "域设置", "domainSettingDescription": "配置域设置", "preferWildcardCertDescription": "尝试生成通配符证书(需要正确配置的证书解析器)。", "recordName": "记录名称", "auto": "自动操作", "TTL": "TTL", "howToAddRecords": "如何添加记录", "dnsRecord": "DNS记录", "required": "必填", "domainSettingsUpdated": "域设置更新成功", "orgOrDomainIdMissing": "缺少机构或域 ID", "loadingDNSRecords": "正在载入DNS记录...", "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。", "client": "客户端:", "proxyProtocol": "代理协议设置", "proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。", "enableProxyProtocol": "启用代理协议", "proxyProtocolInfo": "为TCP后端保留客户端IP地址", "proxyProtocolVersion": "代理协议版本", "version1": " 版本 1 (推荐)", "version2": "版本 2", "versionDescription": "版本 1 是基于文本和广泛支持的版本。版本 2 是二进制和更有效率但不那么兼容。", "warning": "警告", "proxyProtocolWarning": "后端应用程序必须配置为接受代理协议连接。 如果您的后端不支持代理协议,启用此功能将会中断所有连接,只有当您知道自己在做什么时才能启用此功能。 请务必从Traefik配置您的后端到信任代理协议标题。", "restarting": "正在重启...", "manual": "手动模式", "messageSupport": "消息支持", "supportNotAvailableTitle": "支持不可用", "supportNotAvailableDescription": "支持现在不可用。您可以发送电子邮件到 support@pangolin.net。", "supportRequestSentTitle": "支持请求已发送", "supportRequestSentDescription": "您的消息已成功发送。", "supportRequestFailedTitle": "发送请求失败", "supportRequestFailedDescription": "发送您的支持请求时出错。", "supportSubjectRequired": "主题是必填项", "supportSubjectMaxLength": "主题必须是255个或更少的字符", "supportMessageRequired": "消息是必填项", "supportReplyTo": "回复给", "supportSubject": "议 题", "supportSubjectPlaceholder": "输入主题", "supportMessage": "留言", "supportMessagePlaceholder": "输入您的消息", "supportSending": "正在发送...", "supportSend": "发送", "supportMessageSent": "消息已发送!", "supportWillContact": "我们很快就会联系起来!", "selectLogRetention": "选择保留日志", "terms": "条款", "privacy": "隐私", "security": "安全", "docs": "文档", "deviceActivation": "设备激活", "deviceCodeInvalidFormat": "代码必须是9个字符(如A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "无效或过期的代码", "deviceCodeVerifyFailed": "验证设备代码失败", "deviceCodeValidating": "正在验证设备代码...", "deviceCodeVerifying": "正在验证设备授权...", "signedInAs": "登录为", "deviceCodeEnterPrompt": "输入设备上显示的代码", "continue": "继续", "deviceUnknownLocation": "未知位置", "deviceAuthorizationRequested": "此授权请求来自{location},日期为{date}。请确保您信任此设备,因为它将获得帐户访问权限。", "deviceLabel": "设备: {deviceName}", "deviceWantsAccess": "想要访问您的帐户", "deviceExistingAccess": "现有访问权限:", "deviceFullAccess": "完全访问您的帐户", "deviceOrganizationsAccess": "访问您的帐户拥有访问权限的所有组织", "deviceAuthorize": "授权{applicationName}", "deviceConnected": "设备已连接!", "deviceAuthorizedMessage": "设备被授权访问您的帐户。请返回客户端应用程序。", "pangolinCloud": "邦戈林云", "viewDevices": "查看设备", "viewDevicesDescription": "管理您已连接的设备", "noDevices": "未找到设备", "dateCreated": "创建日期", "unnamedDevice": "未命名设备", "deviceQuestionRemove": "您确定要删除此设备吗?", "deviceMessageRemove": "此操作不能撤消。", "deviceDeleteConfirm": "删除设备", "deleteDevice": "删除设备", "errorLoadingDevices": "加载设备时出错", "failedToLoadDevices": "加载设备失败", "deviceDeleted": "设备已删除", "deviceDeletedDescription": "设备已成功删除。", "errorDeletingDevice": "删除设备时出错", "failedToDeleteDevice": "删除设备失败", "showColumns": "显示列", "hideColumns": "隐藏列", "columnVisibility": "列可见性", "toggleColumn": "切换 {columnName} 列", "allColumns": "全部列", "defaultColumns": "默认列", "customizeView": "自定义视图", "viewOptions": "查看选项", "selectAll": "选择所有", "selectNone": "没有选择", "selectedResources": "选定的资源", "enableSelected": "启用选中的", "disableSelected": "禁用选中的", "checkSelectedStatus": "检查选中的状态", "clients": "客户端", "accessClientSelect": "选择机器客户端", "resourceClientDescription": "机器客户端可以访问此资源", "regenerate": "重新生成", "credentials": "全权证书", "savecredentials": "保存证书", "regenerateCredentialsButton": "重新生成证书", "regenerateCredentials": "重新生成证书", "generatedcredentials": "生成的证书", "copyandsavethesecredentials": "复制和保存这些凭据", "copyandsavethesecredentialsdescription": "这些凭据将不会在您离开此页面后再显示。现在安全地保存。", "credentialsSaved": "凭据已保存", "credentialsSavedDescription": "已成功生成和保存凭据。", "credentialsSaveError": "证书保存错误", "credentialsSaveErrorDescription": "更新和保存凭据时出错。", "regenerateCredentialsWarning": "重新生成凭据将使以前的凭据失效并导致断开连接。请确保更新使用这些凭据的任何配置。", "confirm": "确认", "regenerateCredentialsConfirmation": "您确定要重新生成凭据吗?", "endpoint": "Endpoint", "Id": "Id", "SecretKey": "秘密密钥", "niceId": "好的 ID", "niceIdUpdated": "好的 ID 已更新", "niceIdUpdatedSuccessfully": "Nice ID 更新成功", "niceIdUpdateError": "更新Nice ID时出错", "niceIdUpdateErrorDescription": "更新Nice ID时出错。", "niceIdCannotBeEmpty": "好的 ID 不能为空", "enterIdentifier": "输入标识符", "identifier": "Identifier", "deviceLoginUseDifferentAccount": "不是你?使用一个不同的帐户。", "deviceLoginDeviceRequestingAccessToAccount": "设备正在请求访问此帐户。", "loginSelectAuthenticationMethod": "选择要继续的身份验证方法。", "noData": "无数据", "machineClients": "机器客户端", "install": "安装", "run": "运行", "clientNameDescription": "可以稍后更改的客户端的显示名称。", "clientAddress": "客户端地址 (高级)", "setupFailedToFetchSubnet": "获取默认子网失败", "setupSubnetAdvanced": "子网 (高级)", "setupSubnetDescription": "该组织内部网络的子网。", "setupUtilitySubnet": "实用子网(高级)", "setupUtilitySubnetDescription": "此组织的别名地址和DNS服务器的子网。", "siteRegenerateAndDisconnect": "重新生成和断开", "siteRegenerateAndDisconnectConfirmation": "您确定要重新生成凭据并断开此站点连接吗?", "siteRegenerateAndDisconnectWarning": "这将重新生成凭据并立即断开站点。该站点将需要重新启动新凭据。", "siteRegenerateCredentialsConfirmation": "您确定要重新生成此站点的凭据吗?", "siteRegenerateCredentialsWarning": "这将重新生成凭据。站点将保持连接,直到您手动重启并使用新凭据。", "clientRegenerateAndDisconnect": "重新生成和断开", "clientRegenerateAndDisconnectConfirmation": "您确定要重新生成凭据并断开此客户端连接吗?", "clientRegenerateAndDisconnectWarning": "这将重新生成凭据并立即断开客户端。客户端需要重新启动新凭据。", "clientRegenerateCredentialsConfirmation": "您确定要重新生成此客户端的凭据吗?", "clientRegenerateCredentialsWarning": "这将重新生成凭据。客户端将保持连接,直到您手动重启它并使用新凭据。", "remoteExitNodeRegenerateAndDisconnect": "重新生成和断开", "remoteExitNodeRegenerateAndDisconnectConfirmation": "您确定要重新生成凭据并断开此远程退出节点?", "remoteExitNodeRegenerateAndDisconnectWarning": "这将重新生成凭据并立即断开远程退出节点。远程退出节点将需要用新的凭据重启。", "remoteExitNodeRegenerateCredentialsConfirmation": "您确定要重新生成此远程退出节点的凭据吗?", "remoteExitNodeRegenerateCredentialsWarning": "这将重新生成凭据。远程退出节点将保持连接,直到您手动重启它并使用新凭据。", "agent": "代理", "personalUseOnly": "仅供个人使用", "loginPageLicenseWatermark": "此实例仅限于个人使用许可。", "instanceIsUnlicensed": "此实例未获得许可。", "portRestrictions": "端口限制", "allPorts": "所有", "custom": "自定义", "allPortsAllowed": "所有端口均允许", "allPortsBlocked": "所有端口均阻止", "tcpPortsDescription": "指定允许此资源使用的TCP端口。使用'*'表示所有端口,留空表示阻止所有端口,或输入用逗号分隔的端口和范围列表(例如:80,443,8000-9000)。", "udpPortsDescription": "指定允许此资源使用的UDP端口。使用'*'表示所有端口,留空表示阻止所有端口,或输入用逗号分隔的端口和范围列表(例如:53,123,500-600)。", "organizationLoginPageTitle": "组织登录页面", "organizationLoginPageDescription": "自定义此组织的登录页面", "resourceLoginPageTitle": "资源登录页面", "resourceLoginPageDescription": "自定义个别资源的登录页面", "enterConfirmation": "输入确认", "blueprintViewDetails": "详细信息", "defaultIdentityProvider": "默认身份提供商", "defaultIdentityProviderDescription": "当选择默认身份提供商时,用户将自动重定向到提供商进行身份验证。", "editInternalResourceDialogNetworkSettings": "网络设置", "editInternalResourceDialogAccessPolicy": "访问策略", "editInternalResourceDialogAddRoles": "添加角色", "editInternalResourceDialogAddUsers": "添加用户", "editInternalResourceDialogAddClients": "添加客户端", "editInternalResourceDialogDestinationLabel": "目标", "editInternalResourceDialogDestinationDescription": "指定内部资源的目标地址。根据选择的模式,这可以是主机名、IP地址或CIDR范围。可选的,设置一个内部DNS别名以便于识别。", "editInternalResourceDialogPortRestrictionsDescription": "限制访问特定的TCP/UDP端口或允许/阻止所有端口。", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "访问控制", "editInternalResourceDialogAccessControlDescription": "控制当连接到此资源时,哪些角色、用户和机器客户端可以访问。管理员始终具有访问权。", "editInternalResourceDialogPortRangeValidationError": "端口范围必须为\"*\"表示所有端口,或一个用逗号分隔的端口和范围列表(例如:\"80,443,8000-9000\")。端口必须在1到65535之间。", "internalResourceAuthDaemonStrategy": "SSH 认证守护进程位置", "internalResourceAuthDaemonStrategyDescription": "选择 SSH 身份验证守护进程在哪里运行:站点(新建) 或远程主机。", "internalResourceAuthDaemonDescription": "SSH 身份验证守护程序处理此资源的 SSH 密钥签名和PAM 身份验证。 选择它是在站点(新建)还是在单独的远程主机上运行。请参阅 文档。", "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", "internalResourceAuthDaemonStrategyPlaceholder": "选择策略", "internalResourceAuthDaemonStrategyLabel": "地点", "internalResourceAuthDaemonSite": "在站点", "internalResourceAuthDaemonSiteDescription": "认证守护进程在站点上运行(新建)。", "internalResourceAuthDaemonRemote": "远程主机", "internalResourceAuthDaemonRemoteDescription": "认证守护进程运行在不是站点的主机上。", "internalResourceAuthDaemonPort": "守护进程端口(可选)", "orgAuthWhatsThis": "我的组织ID在哪里可以找到?", "learnMore": "了解更多", "backToHome": "返回首页", "needToSignInToOrg": "需要使用您组织的身份提供商吗?", "maintenanceMode": "维护模式", "maintenanceModeDescription": "向访客显示维护页面", "maintenanceModeType": "维护模式类型", "showMaintenancePage": "只在所有后端目标都故障或不健康时显示维护页面。只要至少一个目标健康,您的资源将正常工作。", "enableMaintenanceMode": "启用维护模式", "automatic": "自动", "automaticModeDescription": "如果所有后端目标都故障或不健康,则仅显示维护页面。只要至少一个目标健康,您的资源将正常工作。", "forced": "强制", "forcedModeDescription": "无论后端健康如何,都始终显示维护页面。用于计划维护时希望阻止所有访问。", "warning:": "警告:", "forcedeModeWarning": "所有流量将被引导到维护页面。您的后端资源不会收到任何请求。", "pageTitle": "页面标题", "pageTitleDescription": "维护页面显示的主标题", "maintenancePageMessage": "维护信息", "maintenancePageMessagePlaceholder": "我们很快回来! 我们的网站目前正在进行计划中的维护。", "maintenancePageMessageDescription": "详细说明维护的消息", "maintenancePageTimeTitle": "预计完成时间(可选)", "maintenanceTime": "例如,2小时,11月1日下午5:00", "maintenanceEstimatedTimeDescription": "您期望维护完成的时间", "editDomain": "编辑域名", "editDomainDescription": "选择您资源的域", "maintenanceModeDisabledTooltip": "启用此功能需要有效的许可证。", "maintenanceScreenTitle": "服务暂时不可用", "maintenanceScreenMessage": "我们目前遇到技术问题。 请稍后再回来查看。", "maintenanceScreenEstimatedCompletion": "预计完成时间:", "createInternalResourceDialogDestinationRequired": "需要目标地址", "available": "可用", "archived": "已存档", "noArchivedDevices": "未找到存档设备", "deviceArchived": "设备已存档", "deviceArchivedDescription": "设备已成功归档。", "errorArchivingDevice": "错误存档设备", "failedToArchiveDevice": "归档设备失败", "deviceQuestionArchive": "您确定要存档此设备吗?", "deviceMessageArchive": "设备将被存档并从活动设备列表中删除。", "deviceArchiveConfirm": "归档设备", "archiveDevice": "归档设备", "archive": "存档", "deviceUnarchived": "设备未存档", "deviceUnarchivedDescription": "设备已成功解除归档。", "errorUnarchivingDevice": "卸载设备时出错", "failedToUnarchiveDevice": "取消归档设备失败", "unarchive": "取消存档", "archiveClient": "归档客户端", "archiveClientQuestion": "您确定要存档此客户端吗?", "archiveClientMessage": "客户端将被存档并从您活跃的客户端列表中删除。", "archiveClientConfirm": "归档客户端", "blockClient": "屏蔽客户端", "blockClientQuestion": "您确定要屏蔽此客户端?", "blockClientMessage": "如果当前连接,设备将被迫断开连接。您可以稍后取消屏蔽设备。", "blockClientConfirm": "屏蔽客户端", "active": "已启用", "usernameOrEmail": "用户名或电子邮件", "selectYourOrganization": "选择您的组织", "signInTo": "登录到", "signInWithPassword": "使用密码继续", "noAuthMethodsAvailable": "该组织没有可用的身份验证方法。", "enterPassword": "输入您的密码", "enterMfaCode": "从您的身份验证程序中输入代码", "securityKeyRequired": "请使用您的安全密钥登录。", "needToUseAnotherAccount": "需要使用不同的帐户?", "loginLegalDisclaimer": "点击下面的按钮,您确认您已经阅读了,理解, 并同意 服务条款隐私政策。", "termsOfService": "服务条款", "privacyPolicy": "隐私政策", "userNotFoundWithUsername": "找不到该用户名。", "verify": "验证", "signIn": "登录", "forgotPassword": "忘记密码?", "orgSignInTip": "如果您以前已经登录,您可以在上面输入您的用户名或电子邮件来验证您的组织身份提供者。这很容易!", "continueAnyway": "仍然继续", "dontShowAgain": "不再显示", "orgSignInNotice": "您知道吗?", "signupOrgNotice": "试图登录?", "signupOrgTip": "您是否试图通过您的组织的身份提供者登录?", "signupOrgLink": "使用您的组织登录或注册", "verifyEmailLogInWithDifferentAccount": "使用不同的帐户", "logIn": "登录", "deviceInformation": "设备信息", "deviceInformationDescription": "关于设备和代理的信息", "deviceSecurity": "设备安全", "deviceSecurityDescription": "设备安全态势信息", "platform": "平台", "macosVersion": "macOS 版本", "windowsVersion": "Windows 版本", "iosVersion": "iOS 版本", "androidVersion": "Android 版本", "osVersion": "操作系统版本", "kernelVersion": "内核版本", "deviceModel": "设备模型", "serialNumber": "序列号", "hostname": "Hostname", "firstSeen": "第一次查看", "lastSeen": "上次查看时间", "biometricsEnabled": "生物计已启用", "diskEncrypted": "磁盘加密", "firewallEnabled": "防火墙已启用", "autoUpdatesEnabled": "启用自动更新", "tpmAvailable": "TPM 可用", "windowsAntivirusEnabled": "抗病毒已启用", "macosSipEnabled": "系统完整性保护 (SIP)", "macosGatekeeperEnabled": "Gatekeeper", "macosFirewallStealthMode": "防火墙隐形模式", "linuxAppArmorEnabled": "AppArmor", "linuxSELinuxEnabled": "SELinux", "deviceSettingsDescription": "查看设备信息和设置", "devicePendingApprovalDescription": "此设备正在等待批准", "deviceBlockedDescription": "此设备目前已被屏蔽。除非解除屏蔽,否则无法连接到任何资源。", "unblockClient": "解除屏蔽客户端", "unblockClientDescription": "设备已解除阻止", "unarchiveClient": "取消归档客户端", "unarchiveClientDescription": "设备已被取消存档", "block": "封禁", "unblock": "取消屏蔽", "deviceActions": "设备操作", "deviceActionsDescription": "管理设备状态和访问权限", "devicePendingApprovalBannerDescription": "此设备正在等待批准。在批准之前,它将无法连接到资源。", "connected": "已连接", "disconnected": "断开连接", "approvalsEmptyStateTitle": "设备批准未启用", "approvalsEmptyStateDescription": "在用户连接新设备之前,允许设备批准角色,需要管理员批准。", "approvalsEmptyStateStep1Title": "转到角色", "approvalsEmptyStateStep1Description": "导航到您组织的角色设置来配置设备批准。", "approvalsEmptyStateStep2Title": "启用设备批准", "approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。", "approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核", "approvalsEmptyStateButtonText": "管理角色" } ================================================ FILE: messages/zh-TW.json ================================================ { "setupCreate": "創建您的第一個組織、網站和資源", "headerAuthCompatibilityInfo": "啟用此選項以在缺少驗證令牌時強制回傳 401 未授權回應。這對於不會在沒有伺服器挑戰的情況下發送憑證的瀏覽器或特定 HTTP 函式庫是必需的。", "headerAuthCompatibility": "擴展相容性", "setupNewOrg": "新建組織", "setupCreateOrg": "創建組織", "setupCreateResources": "創建資源", "setupOrgName": "組織名稱", "orgDisplayName": "這是您組織的顯示名稱。", "orgId": "組織ID", "setupIdentifierMessage": "這是您組織的唯一標識符。這是與顯示名稱分開的。", "setupErrorIdentifier": "組織ID 已被使用。請另選一個。", "componentsErrorNoMemberCreate": "您目前不是任何組織的成員。創建組織以開始操作。", "componentsErrorNoMember": "您目前不是任何組織的成員。", "welcome": "歡迎使用 Pangolin", "welcomeTo": "歡迎來到", "componentsCreateOrg": "創建組織", "componentsMember": "您屬於 {count, plural, =0 {沒有組織} one {一個組織} other {# 個組織}}。", "componentsInvalidKey": "檢測到無效或過期的許可證金鑰。按照許可證條款操作以繼續使用所有功能。", "dismiss": "忽略", "componentsLicenseViolation": "許可證超限:該伺服器使用了 {usedSites} 個站點,已超過授權的 {maxSites} 個。請遵守許可證條款以繼續使用全部功能。", "componentsSupporterMessage": "感謝您的支持!您現在是 Pangolin 的 {tier} 用戶。", "inviteErrorNotValid": "很抱歉,但看起來你試圖訪問的邀請尚未被接受或不再有效。", "inviteErrorUser": "很抱歉,但看起來你想要訪問的邀請不是這個用戶。", "inviteLoginUser": "請確保您以正確的用戶登錄。", "inviteErrorNoUser": "很抱歉,但看起來你想訪問的邀請不是一個存在的用戶。", "inviteCreateUser": "請先創建一個帳戶。", "goHome": "返回首頁", "inviteLogInOtherUser": "以不同的用戶登錄", "createAnAccount": "創建帳戶", "inviteNotAccepted": "邀請未接受", "authCreateAccount": "創建一個帳戶以開始", "authNoAccount": "沒有帳戶?", "email": "電子郵件地址", "password": "密碼", "confirmPassword": "確認密碼", "createAccount": "創建帳戶", "viewSettings": "查看設置", "delete": "刪除", "name": "名稱", "online": "在線", "offline": "離線的", "site": "站點", "dataIn": "數據輸入", "dataOut": "數據輸出", "connectionType": "連接類型", "tunnelType": "隧道類型", "local": "本地的", "edit": "編輯", "siteConfirmDelete": "確認刪除站點", "siteDelete": "刪除站點", "siteMessageRemove": "一旦移除,站點將無法訪問。與站點相關的所有目標也將被移除。", "siteQuestionRemove": "您確定要從組織中刪除該站點嗎?", "siteManageSites": "管理站點", "siteDescription": "允許通過安全隧道連接到您的網路", "sitesBannerTitle": "連接任何網路", "sitesBannerDescription": "站點是與遠端網路的連接,使 Pangolin 能夠為任何地方的使用者提供對公共或私有資源的存取。在任何可以執行二進位檔案或容器的地方安裝站點網路連接器 (Newt) 以建立連接。", "sitesBannerButtonText": "安裝站點", "siteCreate": "創建站點", "siteCreateDescription2": "按照下面的步驟創建和連接一個新站點", "siteCreateDescription": "創建一個新站點開始連接您的資源", "close": "關閉", "siteErrorCreate": "創建站點出錯", "siteErrorCreateKeyPair": "找不到金鑰對或站點預設值", "siteErrorCreateDefaults": "未找到站點預設值", "method": "方法", "siteMethodDescription": "這是您將如何顯示連接。", "siteLearnNewt": "學習如何在您的系統上安裝 Newt", "siteSeeConfigOnce": "您只能看到一次配置。", "siteLoadWGConfig": "正在載入 WireGuard 配置...", "siteDocker": "擴展 Docker 部署詳細資訊", "toggle": "切換", "dockerCompose": "Docker Compose", "dockerRun": "Docker Run", "siteLearnLocal": "本地站點不需要隧道連接,點擊了解更多", "siteConfirmCopy": "我已經複製了配置資訊", "searchSitesProgress": "搜索站點...", "siteAdd": "添加站點", "siteInstallNewt": "安裝 Newt", "siteInstallNewtDescription": "在您的系統中運行 Newt", "WgConfiguration": "WireGuard 配置", "WgConfigurationDescription": "使用以下配置連接到您的網路", "operatingSystem": "操作系統", "commands": "命令", "recommended": "推薦", "siteNewtDescription": "為獲得最佳用戶體驗,請使用 Newt。其底層採用 WireGuard 技術,可直接通過 Pangolin 控制台,使用區域網路地址訪問您私有網路中的資源。", "siteRunsInDocker": "在 Docker 中運行", "siteRunsInShell": "在 macOS 、 Linux 和 Windows 的 Shell 中運行", "siteErrorDelete": "刪除站點出錯", "siteErrorUpdate": "更新站點失敗", "siteErrorUpdateDescription": "更新站點時出錯。", "siteUpdated": "站點已更新", "siteUpdatedDescription": "網站已更新。", "siteGeneralDescription": "配置此站點的常規設置", "siteSettingDescription": "配置您網站上的設置", "siteSetting": "{siteName} 設置", "siteNewtTunnel": "Newt 隧道 (推薦)", "siteNewtTunnelDescription": "最簡單的方式來連接到您的網路。不需要任何額外設置。", "siteWg": "基本 WireGuard", "siteWgDescription": "使用任何 WireGuard 用戶端來建立隧道。需要手動配置 NAT。", "siteWgDescriptionSaas": "使用任何 WireGuard 用戶端建立隧道。需要手動配置 NAT。僅適用於自託管節點。", "siteLocalDescription": "僅限本地資源。不需要隧道。", "siteLocalDescriptionSaas": "僅本地資源。沒有隧道。僅在遠程節點上可用。", "siteSeeAll": "查看所有站點", "siteTunnelDescription": "確定如何連接到您的網站", "siteNewtCredentials": "Newt 憑證", "siteNewtCredentialsDescription": "這是 Newt 伺服器的身份驗證憑證", "remoteNodeCredentialsDescription": "這是遠端節點與伺服器進行驗證的方式", "siteCredentialsSave": "保存您的憑證", "siteCredentialsSaveDescription": "您只能看到一次。請確保將其複製並保存到一個安全的地方。", "siteInfo": "站點資訊", "status": "狀態", "shareTitle": "管理共享連結", "shareDescription": "創建可共享的連結,允許暫時或永久訪問您的資源", "shareSearch": "搜索共享連結...", "shareCreate": "創建共享連結", "shareErrorDelete": "刪除連結失敗", "shareErrorDeleteMessage": "刪除連結時出錯", "shareDeleted": "連結已刪除", "shareDeletedDescription": "連結已刪除", "shareTokenDescription": "您的訪問令牌可以透過兩種方式傳遞:作為查詢參數或請求頭。 每次驗證訪問請求都必須從用戶端傳遞。", "accessToken": "訪問令牌", "usageExamples": "用法範例", "tokenId": "令牌 ID", "requestHeades": "請求頭", "queryParameter": "查詢參數", "importantNote": "重要提示", "shareImportantDescription": "出於安全考慮,建議盡可能在使用請求頭傳遞參數,因為查詢參數可能會被瀏覽器歷史記錄或伺服器日誌記錄。", "token": "令牌", "shareTokenSecurety": "請妥善保管您的訪問令牌,不要將其暴露在公開訪問的區域或用戶端代碼中。", "shareErrorFetchResource": "獲取資源失敗", "shareErrorFetchResourceDescription": "獲取資源時出錯", "shareErrorCreate": "無法創建共享連結", "shareErrorCreateDescription": "創建共享連結時出錯", "shareCreateDescription": "任何具有此連結的人都可以訪問資源", "shareTitleOptional": "標題 (可選)", "expireIn": "過期時間", "neverExpire": "永不過期", "shareExpireDescription": "過期時間是連結可以使用並提供對資源的訪問時間。 此時間後,連結將不再工作,使用此連結的用戶將失去對資源的訪問。", "shareSeeOnce": "您只能看到一次此連結。請確保複製它。", "shareAccessHint": "任何具有此連結的人都可以訪問該資源。小心地分享它。", "shareTokenUsage": "查看訪問令牌使用情況", "createLink": "創建連結", "resourcesNotFound": "找不到資源", "resourceSearch": "搜索資源", "openMenu": "打開菜單", "resource": "資源", "title": "標題", "created": "已創建", "expires": "過期時間", "never": "永不過期", "shareErrorSelectResource": "請選擇一個資源", "proxyResourceTitle": "管理公開資源", "proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源", "proxyResourcesBannerTitle": "基於網頁的公開存取", "proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。", "clientResourceTitle": "管理私有資源", "clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源", "privateResourcesBannerTitle": "零信任私有存取", "privateResourcesBannerDescription": "私有資源使用零信任安全性,確保使用者和機器只能存取您明確授權的資源。連接使用者裝置或機器客戶端以透過安全的虛擬私人網路存取這些資源。", "resourcesSearch": "搜索資源...", "resourceAdd": "添加資源", "resourceErrorDelte": "刪除資源時出錯", "authentication": "認證", "protected": "受到保護", "notProtected": "未受到保護", "resourceMessageRemove": "一旦刪除,資源將不再可訪問。與該資源相關的所有目標也將被刪除。", "resourceQuestionRemove": "您確定要從組織中刪除資源嗎?", "resourceHTTP": "HTTPS 資源", "resourceHTTPDescription": "使用子域或根域名通過 HTTPS 向您的應用程式提出代理請求。", "resourceRaw": "TCP/UDP 資源", "resourceRawDescription": "使用 TCP/UDP 使用埠號向您的應用提出代理請求。", "resourceCreate": "創建資源", "resourceCreateDescription": "按照下面的步驟創建新資源", "resourceSeeAll": "查看所有資源", "resourceInfo": "資源資訊", "resourceNameDescription": "這是資源的顯示名稱。", "siteSelect": "選擇站點", "siteSearch": "搜索站點", "siteNotFound": "未找到站點。", "selectCountry": "選擇國家", "searchCountries": "搜索國家...", "noCountryFound": "找不到國家。", "siteSelectionDescription": "此站點將為目標提供連接。", "resourceType": "資源類型", "resourceTypeDescription": "確定如何訪問您的資源", "resourceHTTPSSettings": "HTTPS 設置", "resourceHTTPSSettingsDescription": "配置如何通過 HTTPS 訪問您的資源", "domainType": "域類型", "subdomain": "子域名", "baseDomain": "根域名", "subdomnainDescription": "您的資源可以訪問的子域名。", "resourceRawSettings": "TCP/UDP 設置", "resourceRawSettingsDescription": "設定如何透過 TCP/UDP 存取資源", "protocol": "協議", "protocolSelect": "選擇協議", "resourcePortNumber": "埠號", "resourcePortNumberDescription": "代理請求的外部埠號。", "cancel": "取消", "resourceConfig": "配置片段", "resourceConfigDescription": "複製並黏貼這些配置片段以設置您的 TCP/UDP 資源", "resourceAddEntrypoints": "Traefik: 添加入口點", "resourceExposePorts": "Gerbil:在 Docker Compose 中顯示埠", "resourceLearnRaw": "學習如何配置 TCP/UDP 資源", "resourceBack": "返回資源", "resourceGoTo": "轉到資源", "resourceDelete": "刪除資源", "resourceDeleteConfirm": "確認刪除資源", "visibility": "可見性", "enabled": "已啟用", "disabled": "已禁用", "general": "概覽", "generalSettings": "常規設置", "proxy": "代理伺服器", "internal": "內部設置", "rules": "規則", "resourceSettingDescription": "配置您資源上的設置", "resourceSetting": "{resourceName} 設置", "alwaysAllow": "一律允許", "alwaysDeny": "一律拒絕", "passToAuth": "傳遞至認證", "orgSettingsDescription": "配置您組織的一般設定", "orgGeneralSettings": "組織設置", "orgGeneralSettingsDescription": "管理您的機構詳細資訊和配置", "saveGeneralSettings": "保存常規設置", "saveSettings": "保存設置", "orgDangerZone": "危險區域", "orgDangerZoneDescription": "一旦刪除該組織,將無法恢復,請務必確認。", "orgDelete": "刪除組織", "orgDeleteConfirm": "確認刪除組織", "orgMessageRemove": "此操作不可逆,這將刪除所有相關數據。", "orgMessageConfirm": "要確認,請在下面輸入組織名稱。", "orgQuestionRemove": "您確定要刪除組織嗎?", "orgUpdated": "組織已更新", "orgUpdatedDescription": "組織已更新。", "orgErrorUpdate": "更新組織失敗", "orgErrorUpdateMessage": "更新組織時出錯。", "orgErrorFetch": "獲取組織失敗", "orgErrorFetchMessage": "列出您的組織時出錯", "orgErrorDelete": "刪除組織失敗", "orgErrorDeleteMessage": "刪除組織時出錯。", "orgDeleted": "組織已刪除", "orgDeletedMessage": "組織及其數據已被刪除。", "orgMissing": "缺少組織 ID", "orgMissingMessage": "沒有組織ID,無法重新生成邀請。", "accessUsersManage": "管理用戶", "accessUsersDescription": "邀請用戶並位他們添加角色以管理訪問您的組織", "accessUsersSearch": "搜索用戶...", "accessUserCreate": "創建用戶", "accessUserRemove": "刪除用戶", "username": "使用者名稱", "identityProvider": "身份提供商", "role": "角色", "nameRequired": "名稱是必填項", "accessRolesManage": "管理角色", "accessRolesDescription": "配置角色來管理訪問您的組織", "accessRolesSearch": "搜索角色...", "accessRolesAdd": "添加角色", "accessRoleDelete": "刪除角色", "description": "描述", "inviteTitle": "打開邀請", "inviteDescription": "管理您給其他用戶的邀請", "inviteSearch": "搜索邀請...", "minutes": "分鐘", "hours": "小時", "days": "天", "weeks": "周", "months": "月", "years": "年", "day": "{count, plural, other {# 天}}", "apiKeysTitle": "API 金鑰", "apiKeysConfirmCopy2": "您必須確認您已複製 API 金鑰。", "apiKeysErrorCreate": "創建 API 金鑰出錯", "apiKeysErrorSetPermission": "設置權限出錯", "apiKeysCreate": "生成 API 金鑰", "apiKeysCreateDescription": "為您的組織生成一個新的 API 金鑰", "apiKeysGeneralSettings": "權限", "apiKeysGeneralSettingsDescription": "確定此 API 金鑰可以做什麼", "apiKeysList": "您的 API 金鑰", "apiKeysSave": "保存您的 API 金鑰", "apiKeysSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全的位置。", "apiKeysInfo": "您的 API 金鑰是:", "apiKeysConfirmCopy": "我已複製 API 金鑰", "generate": "生成", "done": "完成", "apiKeysSeeAll": "查看所有 API 金鑰", "apiKeysPermissionsErrorLoadingActions": "載入 API 金鑰操作時出錯", "apiKeysPermissionsErrorUpdate": "設置權限出錯", "apiKeysPermissionsUpdated": "權限已更新", "apiKeysPermissionsUpdatedDescription": "權限已更新。", "apiKeysPermissionsGeneralSettings": "權限", "apiKeysPermissionsGeneralSettingsDescription": "確定此 API 金鑰可以做什麼", "apiKeysPermissionsSave": "保存權限", "apiKeysPermissionsTitle": "權限", "apiKeys": "API 金鑰", "searchApiKeys": "搜索 API 金鑰...", "apiKeysAdd": "生成 API 金鑰", "apiKeysErrorDelete": "刪除 API 金鑰出錯", "apiKeysErrorDeleteMessage": "刪除 API 金鑰出錯", "apiKeysQuestionRemove": "您確定要從組織中刪除 API 金鑰嗎?", "apiKeysMessageRemove": "一旦刪除,此API金鑰將無法被使用。", "apiKeysDeleteConfirm": "確認刪除 API 金鑰", "apiKeysDelete": "刪除 API 金鑰", "apiKeysManage": "管理 API 金鑰", "apiKeysDescription": "API 金鑰用於認證集成 API", "apiKeysSettings": "{apiKeyName} 設置", "userTitle": "管理所有用戶", "userDescription": "查看和管理系統中的所有用戶", "userAbount": "關於用戶管理", "userAbountDescription": "此表格顯示系統中所有根用戶對象。每個用戶可能屬於多個組織。 從組織中刪除用戶不會刪除其根用戶對象 - 他們將保留在系統中。 要從系統中完全刪除用戶,您必須使用此表格中的刪除操作刪除其根用戶對象。", "userServer": "伺服器用戶", "userSearch": "搜索伺服器用戶...", "userErrorDelete": "刪除用戶時出錯", "userDeleteConfirm": "確認刪除用戶", "userDeleteServer": "從伺服器刪除用戶", "userMessageRemove": "該用戶將被從所有組織中刪除並完全從伺服器中刪除。", "userQuestionRemove": "您確定要從伺服器永久刪除用戶嗎?", "licenseKey": "許可證金鑰", "valid": "有效", "numberOfSites": "站點數量", "licenseKeySearch": "搜索許可證金鑰...", "licenseKeyAdd": "添加許可證金鑰", "type": "類型", "licenseKeyRequired": "需要許可證金鑰", "licenseTermsAgree": "您必須同意許可條款", "licenseErrorKeyLoad": "載入許可證金鑰失敗", "licenseErrorKeyLoadDescription": "載入許可證金鑰時出錯。", "licenseErrorKeyDelete": "刪除許可證金鑰失敗", "licenseErrorKeyDeleteDescription": "刪除許可證金鑰時出錯。", "licenseKeyDeleted": "許可證金鑰已刪除", "licenseKeyDeletedDescription": "許可證金鑰已被刪除。", "licenseErrorKeyActivate": "啟用許可證金鑰失敗", "licenseErrorKeyActivateDescription": "啟用許可證金鑰時出錯。", "licenseAbout": "關於許可協議", "communityEdition": "社區版", "licenseAboutDescription": "這是針對商業環境中使用Pangolin的商業和企業用戶。 如果您正在使用 Pangolin 供個人使用,您可以忽略此部分。", "licenseKeyActivated": "授權金鑰已啟用", "licenseKeyActivatedDescription": "已成功啟用許可證金鑰。", "licenseErrorKeyRecheck": "重新檢查許可證金鑰失敗", "licenseErrorKeyRecheckDescription": "重新檢查許可證金鑰時出錯。", "licenseErrorKeyRechecked": "重新檢查許可證金鑰", "licenseErrorKeyRecheckedDescription": "已重新檢查所有許可證金鑰", "licenseActivateKey": "啟用許可證金鑰", "licenseActivateKeyDescription": "輸入一個許可金鑰來啟用它。", "licenseActivate": "啟用許可證", "licenseAgreement": "通過檢查此框,您確認您已經閱讀並同意與您的許可證金鑰相關的許可條款。", "fossorialLicense": "查看Fossorial Commercial License和訂閱條款", "licenseMessageRemove": "這將刪除許可證金鑰和它授予的所有相關權限。", "licenseMessageConfirm": "要確認,請在下面輸入許可證金鑰。", "licenseQuestionRemove": "您確定要刪除許可證金鑰?", "licenseKeyDelete": "刪除許可證金鑰", "licenseKeyDeleteConfirm": "確認刪除許可證金鑰", "licenseTitle": "管理許可證狀態", "licenseTitleDescription": "查看和管理系統中的許可證金鑰", "licenseHost": "主機許可證", "licenseHostDescription": "管理主機的主許可證金鑰。", "licensedNot": "未授權", "hostId": "主機 ID", "licenseReckeckAll": "重新檢查所有金鑰", "licenseSiteUsage": "站點使用情況", "licenseSiteUsageDecsription": "查看使用此許可的站點數量。", "licenseNoSiteLimit": "使用未經許可主機的站點數量沒有限制。", "licensePurchase": "購買許可證", "licensePurchaseSites": "購買更多站點", "licenseSitesUsedMax": "使用了 {usedSites}/{maxSites} 個站點", "licenseSitesUsed": "{count, plural, =0 {# 站點} one {# 站點} other {# 站點}}", "licensePurchaseDescription": "請選擇您希望 {selectedMode, select, license {直接購買許可證,您可以隨時增加更多站點。} other {為現有許可證購買更多站點}}", "licenseFee": "許可證費用", "licensePriceSite": "每個站點的價格", "total": "總計", "licenseContinuePayment": "繼續付款", "pricingPage": "定價頁面", "pricingPortal": "前往付款頁面", "licensePricingPage": "關於最新的價格和折扣,請訪問 ", "invite": "邀請", "inviteRegenerate": "重新生成邀請", "inviteRegenerateDescription": "撤銷以前的邀請並創建一個新的邀請", "inviteRemove": "移除邀請", "inviteRemoveError": "刪除邀請失敗", "inviteRemoveErrorDescription": "刪除邀請時出錯。", "inviteRemoved": "邀請已刪除", "inviteRemovedDescription": "為 {email} 創建的邀請已刪除", "inviteQuestionRemove": "您確定要刪除邀請嗎?", "inviteMessageRemove": "一旦刪除,這個邀請將不再有效。", "inviteMessageConfirm": "要確認,請在下面輸入邀請的電子郵件地址。", "inviteQuestionRegenerate": "您確定要重新邀請 {email} 嗎?這將會撤銷掉之前的邀請", "inviteRemoveConfirm": "確認刪除邀請", "inviteRegenerated": "重新生成邀請", "inviteSent": "邀請郵件已成功發送至 {email}。", "inviteSentEmail": "發送電子郵件通知給用戶", "inviteGenerate": "已為 {email} 創建新的邀請。", "inviteDuplicateError": "重複的邀請", "inviteDuplicateErrorDescription": "此用戶的邀請已存在。", "inviteRateLimitError": "超出速率限制", "inviteRateLimitErrorDescription": "您超過了每小時3次再生的限制。請稍後再試。", "inviteRegenerateError": "重新生成邀請失敗", "inviteRegenerateErrorDescription": "重新生成邀請時出錯。", "inviteValidityPeriod": "有效期", "inviteValidityPeriodSelect": "選擇有效期", "inviteRegenerateMessage": "邀請已重新生成。用戶必須訪問下面的連結才能接受邀請。", "inviteRegenerateButton": "重新生成", "expiresAt": "到期於", "accessRoleUnknown": "未知角色", "placeholder": "占位符", "userErrorOrgRemove": "刪除用戶失敗", "userErrorOrgRemoveDescription": "刪除用戶時出錯。", "userOrgRemoved": "用戶已刪除", "userOrgRemovedDescription": "已將 {email} 從組織中移除。", "userQuestionOrgRemove": "您確定要從組織中刪除此用戶嗎?", "userMessageOrgRemove": "一旦刪除,這個用戶將不再能夠訪問組織。 你總是可以稍後重新邀請他們,但他們需要再次接受邀請。", "userRemoveOrgConfirm": "確認刪除用戶", "userRemoveOrg": "從組織中刪除用戶", "users": "用戶", "accessRoleMember": "成員", "accessRoleOwner": "所有者", "userConfirmed": "已確認", "idpNameInternal": "內部設置", "emailInvalid": "無效的電子郵件地址", "inviteValidityDuration": "請選擇持續時間", "accessRoleSelectPlease": "請選擇一個角色", "usernameRequired": "必須輸入使用者名稱", "idpSelectPlease": "請選擇身份提供商", "idpGenericOidc": "通用的 OAuth2/OIDC 提供商。", "accessRoleErrorFetch": "獲取角色失敗", "accessRoleErrorFetchDescription": "獲取角色時出錯", "idpErrorFetch": "獲取身份提供者失敗", "idpErrorFetchDescription": "獲取身份提供者時出錯", "userErrorExists": "用戶已存在", "userErrorExistsDescription": "此用戶已經是組織成員。", "inviteError": "邀請用戶失敗", "inviteErrorDescription": "邀請用戶時出錯", "userInvited": "用戶邀請", "userInvitedDescription": "用戶已被成功邀請。", "userErrorCreate": "創建用戶失敗", "userErrorCreateDescription": "創建用戶時出錯", "userCreated": "用戶已創建", "userCreatedDescription": "用戶已成功創建。", "userTypeInternal": "內部用戶", "userTypeInternalDescription": "邀請用戶直接加入您的組織。", "userTypeExternal": "外部用戶", "userTypeExternalDescription": "創建一個具有外部身份提供商的用戶。", "accessUserCreateDescription": "按照下面的步驟創建一個新用戶", "userSeeAll": "查看所有用戶", "userTypeTitle": "用戶類型", "userTypeDescription": "確定如何創建用戶", "userSettings": "用戶資訊", "userSettingsDescription": "輸入新用戶的詳細資訊", "inviteEmailSent": "發送邀請郵件給用戶", "inviteValid": "有效", "selectDuration": "選擇持續時間", "selectResource": "選擇資源", "filterByResource": "依資源篩選", "resetFilters": "重設篩選條件", "totalBlocked": "被 Pangolin 阻擋的請求", "totalRequests": "總請求數", "requestsByCountry": "依國家/地區的請求", "requestsByDay": "依日期的請求", "blocked": "已阻擋", "allowed": "已允許", "topCountries": "熱門國家/地區", "accessRoleSelect": "選擇角色", "inviteEmailSentDescription": "一封電子郵件已經發送給用戶,帶有下面的訪問連結。他們必須訪問該連結才能接受邀請。", "inviteSentDescription": "用戶已被邀請。他們必須訪問下面的連結才能接受邀請。", "inviteExpiresIn": "邀請將在{days, plural, other {# 天}}後過期。", "idpTitle": "身份提供商", "idpSelect": "為外部用戶選擇身份提供商", "idpNotConfigured": "沒有配置身份提供者。請在創建外部用戶之前配置身份提供者。", "usernameUniq": "這必須匹配所選身份提供者中存在的唯一使用者名稱。", "emailOptional": "電子郵件(可選)", "nameOptional": "名稱(可選)", "accessControls": "訪問控制", "userDescription2": "管理此用戶的設置", "accessRoleErrorAdd": "添加用戶到角色失敗", "accessRoleErrorAddDescription": "添加用戶到角色時出錯。", "userSaved": "用戶已保存", "userSavedDescription": "用戶已更新。", "autoProvisioned": "自動設置", "autoProvisionedDescription": "允許此用戶由身份提供商自動管理", "accessControlsDescription": "管理此用戶在組織中可以訪問和做什麼", "accessControlsSubmit": "保存訪問控制", "roles": "角色", "accessUsersRoles": "管理用戶和角色", "accessUsersRolesDescription": "邀請用戶並將他們添加到角色以管理訪問您的組織", "key": "關鍵字", "createdAt": "創建於", "proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。", "proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。", "proxyEnableSSL": "啟用 SSL", "proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。", "target": "目標", "configureTarget": "配置目標", "targetErrorFetch": "獲取目標失敗", "targetErrorFetchDescription": "獲取目標時出錯", "siteErrorFetch": "獲取資源失敗", "siteErrorFetchDescription": "獲取資源時出錯", "targetErrorDuplicate": "重複的目標", "targetErrorDuplicateDescription": "具有這些設置的目標已存在", "targetWireGuardErrorInvalidIp": "無效的目標IP", "targetWireGuardErrorInvalidIpDescription": "目標IP必須在站點子網內", "targetsUpdated": "目標已更新", "targetsUpdatedDescription": "目標和設置更新成功", "targetsErrorUpdate": "更新目標失敗", "targetsErrorUpdateDescription": "更新目標時出錯", "targetTlsUpdate": "TLS 設置已更新", "targetTlsUpdateDescription": "您的 TLS 設置已成功更新", "targetErrorTlsUpdate": "更新 TLS 設置失敗", "targetErrorTlsUpdateDescription": "更新 TLS 設置時出錯", "proxyUpdated": "代理設置已更新", "proxyUpdatedDescription": "您的代理設置已成功更新", "proxyErrorUpdate": "更新代理設置失敗", "proxyErrorUpdateDescription": "更新代理設置時出錯", "targetAddr": "IP / 域名", "targetPort": "埠", "targetProtocol": "協議", "targetTlsSettings": "安全連接配置", "targetTlsSettingsDescription": "配置資源的 SSL/TLS 設置", "targetTlsSettingsAdvanced": "高級TLS設置", "targetTlsSni": "TLS 伺服器名稱", "targetTlsSniDescription": "SNI使用的 TLS 伺服器名稱。留空使用預設值。", "targetTlsSubmit": "保存設置", "targets": "目標配置", "targetsDescription": "設置目標來路由流量到您的後端服務", "targetStickySessions": "啟用置頂會話", "targetStickySessionsDescription": "將連接保持在同一個後端目標的整個會話中。", "methodSelect": "選擇方法", "targetSubmit": "添加目標", "targetNoOne": "此資源沒有任何目標。添加目標來配置向您後端發送請求的位置。", "targetNoOneDescription": "在上面添加多個目標將啟用負載平衡。", "targetsSubmit": "保存目標", "addTarget": "添加目標", "targetErrorInvalidIp": "無效的 IP 地址", "targetErrorInvalidIpDescription": "請輸入有效的IP位址或主機名", "targetErrorInvalidPort": "無效的埠", "targetErrorInvalidPortDescription": "請輸入有效的埠號", "targetErrorNoSite": "沒有選擇站點", "targetErrorNoSiteDescription": "請選擇目標站點", "targetCreated": "目標已創建", "targetCreatedDescription": "目標已成功創建", "targetErrorCreate": "創建目標失敗", "targetErrorCreateDescription": "創建目標時出錯", "tlsServerName": "TLS 伺服器名稱", "tlsServerNameDescription": "用於 SNI 的 TLS 伺服器名稱", "save": "保存", "proxyAdditional": "附加代理設置", "proxyAdditionalDescription": "配置你的資源如何處理代理設置", "proxyCustomHeader": "自訂主機 Header", "proxyCustomHeaderDescription": "代理請求時設置的 Header。留空則使用預設值。", "proxyAdditionalSubmit": "保存代理設置", "subnetMaskErrorInvalid": "子網掩碼無效。必須在 0 和 32 之間。", "ipAddressErrorInvalidFormat": "無效的 IP 地址格式", "ipAddressErrorInvalidOctet": "無效的 IP 地址", "path": "路徑", "matchPath": "匹配路徑", "ipAddressRange": "IP 範圍", "rulesErrorFetch": "獲取規則失敗", "rulesErrorFetchDescription": "獲取規則時出錯", "rulesErrorDuplicate": "複製規則", "rulesErrorDuplicateDescription": "帶有這些設置的規則已存在", "rulesErrorInvalidIpAddressRange": "無效的 CIDR", "rulesErrorInvalidIpAddressRangeDescription": "請輸入一個有效的 CIDR 值", "rulesErrorInvalidUrl": "無效的 URL 路徑", "rulesErrorInvalidUrlDescription": "請輸入一個有效的 URL 路徑值", "rulesErrorInvalidIpAddress": "無效的 IP", "rulesErrorInvalidIpAddressDescription": "請輸入一個有效的IP位址", "rulesErrorUpdate": "更新規則失敗", "rulesErrorUpdateDescription": "更新規則時出錯", "rulesUpdated": "啟用規則", "rulesUpdatedDescription": "規則已更新", "rulesMatchIpAddressRangeDescription": "以 CIDR 格式輸入地址(如:103.21.244.0/22)", "rulesMatchIpAddress": "輸入IP位址(例如,103.21.244.12)", "rulesMatchUrl": "輸入一個 URL 路徑或模式(例如/api/v1/todos 或 /api/v1/*)", "rulesErrorInvalidPriority": "無效的優先度", "rulesErrorInvalidPriorityDescription": "請輸入一個有效的優先度", "rulesErrorDuplicatePriority": "重複的優先度", "rulesErrorDuplicatePriorityDescription": "請輸入唯一的優先度", "ruleUpdated": "規則已更新", "ruleUpdatedDescription": "規則更新成功", "ruleErrorUpdate": "操作失敗", "ruleErrorUpdateDescription": "保存過程中發生錯誤", "rulesPriority": "優先權", "rulesAction": "行為", "rulesMatchType": "匹配類型", "value": "值", "rulesAbout": "關於規則", "rulesAboutDescription": "規則使您能夠依據特定條件控制資源訪問權限。您可以創建基於 IP 地址或 URL 路徑的規則,以允許或拒絕訪問。", "rulesActions": "行動", "rulesActionAlwaysAllow": "總是允許:繞過所有身份驗證方法", "rulesActionAlwaysDeny": "總是拒絕:阻止所有請求;無法嘗試驗證", "rulesActionPassToAuth": "傳遞至認證:允許嘗試身份驗證方法", "rulesMatchCriteria": "匹配條件", "rulesMatchCriteriaIpAddress": "匹配一個指定的 IP 地址", "rulesMatchCriteriaIpAddressRange": "在 CIDR 符號中匹配一系列IP位址", "rulesMatchCriteriaUrl": "匹配一個 URL 路徑或模式", "rulesEnable": "啟用規則", "rulesEnableDescription": "啟用或禁用此資源的規則評估", "rulesResource": "資源規則配置", "rulesResourceDescription": "配置規則來控制對您資源的訪問", "ruleSubmit": "添加規則", "rulesNoOne": "沒有規則。使用表單添加規則。", "rulesOrder": "規則按優先順序評定。", "rulesSubmit": "保存規則", "resourceErrorCreate": "創建資源時出錯", "resourceErrorCreateDescription": "創建資源時出錯", "resourceErrorCreateMessage": "創建資源時發生錯誤:", "resourceErrorCreateMessageDescription": "發生意外錯誤", "sitesErrorFetch": "獲取站點出錯", "sitesErrorFetchDescription": "獲取站點時出錯", "domainsErrorFetch": "獲取域名出錯", "domainsErrorFetchDescription": "獲取域時出錯", "none": "無", "unknown": "未知", "resources": "資源", "resourcesDescription": "資源是您私有網路中運行的應用程式的代理。您可以為私有網路中的任何 HTTP/HTTPS 或 TCP/UDP 服務創建資源。每個資源都必須連接到一個站點,以通過加密的 WireGuard 隧道實現私密且安全的連接。", "resourcesWireGuardConnect": "採用 WireGuard 提供的加密安全連接", "resourcesMultipleAuthenticationMethods": "配置多個身份驗證方法", "resourcesUsersRolesAccess": "基於用戶和角色的訪問控制", "resourcesErrorUpdate": "切換資源失敗", "resourcesErrorUpdateDescription": "更新資源時出錯", "access": "訪問權限", "shareLink": "{resource} 的分享連結", "resourceSelect": "選擇資源", "shareLinks": "分享連結", "share": "分享連結", "shareDescription2": "創建資源共享連結。連結提供對資源的臨時或無限制訪問。 當您創建連結時,您可以配置連結的到期時間。", "shareEasyCreate": "輕鬆創建和分享", "shareConfigurableExpirationDuration": "可配置的過期時間", "shareSecureAndRevocable": "安全和可撤銷的", "nameMin": "名稱長度必須大於 {len} 字元。", "nameMax": "名稱長度必須小於 {len} 字元。", "sitesConfirmCopy": "請確認您已經複製了配置。", "unknownCommand": "未知命令", "newtErrorFetchReleases": "無法獲取版本資訊: {err}", "newtErrorFetchLatest": "無法獲取最新版資訊: {err}", "newtEndpoint": "Newt 端點", "newtId": "Newt ID", "newtSecretKey": "Newt 私鑰", "architecture": "架構", "sites": "站點", "siteWgAnyClients": "使用任何 WireGuard 用戶端連接。您必須使用對等IP解決您的內部資源。", "siteWgCompatibleAllClients": "與所有 WireGuard 用戶端相容", "siteWgManualConfigurationRequired": "需要手動配置", "userErrorNotAdminOrOwner": "用戶不是管理員或所有者", "pangolinSettings": "設置 - Pangolin", "accessRoleYour": "您的角色:", "accessRoleSelect2": "選擇角色", "accessUserSelect": "選擇一個用戶", "otpEmailEnter": "輸入電子郵件", "otpEmailEnterDescription": "在輸入欄位輸入後按 Enter 鍵添加電子郵件。", "otpEmailErrorInvalid": "無效的信箱地址。通配符(*)必須占據整個開頭部分。", "otpEmailSmtpRequired": "需要先配置 SMTP", "otpEmailSmtpRequiredDescription": "必須在伺服器上啟用 SMTP 才能使用一次性密碼驗證。", "otpEmailTitle": "一次性密碼", "otpEmailTitleDescription": "資源訪問需要基於電子郵件的身份驗證", "otpEmailWhitelist": "電子郵件白名單", "otpEmailWhitelistList": "白名單郵件", "otpEmailWhitelistListDescription": "只有擁有這些電子郵件地址的用戶才能訪問此資源。 他們將被提示輸入一次性密碼發送到他們的電子郵件。 通配符 (*@example.com) 可以用來允許來自一個域名的任何電子郵件地址。", "otpEmailWhitelistSave": "保存白名單", "passwordAdd": "添加密碼", "passwordRemove": "刪除密碼", "pincodeAdd": "添加 PIN 碼", "pincodeRemove": "移除 PIN 碼", "resourceAuthMethods": "身份驗證方法", "resourceAuthMethodsDescriptions": "允許透過額外的認證方法訪問資源", "resourceAuthSettingsSave": "保存成功", "resourceAuthSettingsSaveDescription": "已保存身份驗證設置", "resourceErrorAuthFetch": "獲取數據失敗", "resourceErrorAuthFetchDescription": "獲取數據時出錯", "resourceErrorPasswordRemove": "刪除資源密碼出錯", "resourceErrorPasswordRemoveDescription": "刪除資源密碼時出錯", "resourceErrorPasswordSetup": "設置資源密碼出錯", "resourceErrorPasswordSetupDescription": "設置資源密碼時出錯", "resourceErrorPincodeRemove": "刪除資源固定碼時出錯", "resourceErrorPincodeRemoveDescription": "刪除資源PIN碼時出錯", "resourceErrorPincodeSetup": "設置資源 PIN 碼時出錯", "resourceErrorPincodeSetupDescription": "設置資源 PIN 碼時發生錯誤", "resourceErrorUsersRolesSave": "設置角色失敗", "resourceErrorUsersRolesSaveDescription": "設置角色時出錯", "resourceErrorWhitelistSave": "保存白名單失敗", "resourceErrorWhitelistSaveDescription": "保存白名單時出錯", "resourcePasswordSubmit": "啟用密碼保護", "resourcePasswordProtection": "密碼保護 {status}", "resourcePasswordRemove": "已刪除資源密碼", "resourcePasswordRemoveDescription": "已成功刪除資源密碼", "resourcePasswordSetup": "設置資源密碼", "resourcePasswordSetupDescription": "已成功設置資源密碼", "resourcePasswordSetupTitle": "設置密碼", "resourcePasswordSetupTitleDescription": "設置密碼來保護此資源", "resourcePincode": "PIN 碼", "resourcePincodeSubmit": "啟用 PIN 碼保護", "resourcePincodeProtection": "PIN 碼保護 {status}", "resourcePincodeRemove": "資源 PIN 碼已刪除", "resourcePincodeRemoveDescription": "已成功刪除資源 PIN 碼", "resourcePincodeSetup": "資源 PIN 碼已設置", "resourcePincodeSetupDescription": "資源 PIN 碼已成功設置", "resourcePincodeSetupTitle": "設置 PIN 碼", "resourcePincodeSetupTitleDescription": "設置 PIN 碼來保護此資源", "resourceRoleDescription": "管理員總是可以訪問此資源。", "resourceUsersRoles": "用戶和角色", "resourceUsersRolesDescription": "配置用戶和角色可以訪問此資源", "resourceUsersRolesSubmit": "保存用戶和角色", "resourceWhitelistSave": "保存成功", "resourceWhitelistSaveDescription": "白名單設置已保存", "ssoUse": "使用平台 SSO", "ssoUseDescription": "對於所有啟用此功能的資源,現有用戶只需登錄一次。", "proxyErrorInvalidPort": "無效的埠號", "subdomainErrorInvalid": "無效的子域", "domainErrorFetch": "獲取域名失敗", "domainErrorFetchDescription": "獲取域名時出錯", "resourceErrorUpdate": "更新資源失敗", "resourceErrorUpdateDescription": "更新資源時出錯", "resourceUpdated": "資源已更新", "resourceUpdatedDescription": "資源已成功更新", "resourceErrorTransfer": "轉移資源失敗", "resourceErrorTransferDescription": "轉移資源時出錯", "resourceTransferred": "資源已傳輸", "resourceTransferredDescription": "資源已成功傳輸", "resourceErrorToggle": "切換資源失敗", "resourceErrorToggleDescription": "更新資源時出錯", "resourceVisibilityTitle": "可見性", "resourceVisibilityTitleDescription": "完全啟用或禁用資源可見性", "resourceGeneral": "常規設置", "resourceGeneralDescription": "配置此資源的常規設置", "resourceEnable": "啟用資源", "resourceTransfer": "轉移資源", "resourceTransferDescription": "將此資源轉移到另一個站點", "resourceTransferSubmit": "轉移資源", "siteDestination": "目標站點", "searchSites": "搜索站點", "countries": "國家/地區", "accessRoleCreate": "創建角色", "accessRoleCreateDescription": "創建一個新角色來分組用戶並管理他們的權限。", "accessRoleCreateSubmit": "創建角色", "accessRoleCreated": "角色已創建", "accessRoleCreatedDescription": "角色已成功創建。", "accessRoleErrorCreate": "創建角色失敗", "accessRoleErrorCreateDescription": "創建角色時出錯。", "accessRoleErrorNewRequired": "需要新角色", "accessRoleErrorRemove": "刪除角色失敗", "accessRoleErrorRemoveDescription": "刪除角色時出錯。", "accessRoleName": "角色名稱", "accessRoleQuestionRemove": "您即將刪除 {name} 角色。 此操作無法撤銷。", "accessRoleRemove": "刪除角色", "accessRoleRemoveDescription": "從組織中刪除角色", "accessRoleRemoveSubmit": "刪除角色", "accessRoleRemoved": "角色已刪除", "accessRoleRemovedDescription": "角色已成功刪除。", "accessRoleRequiredRemove": "刪除此角色之前,請選擇一個新角色來轉移現有成員。", "manage": "管理", "sitesNotFound": "未找到站點。", "pangolinServerAdmin": "伺服器管理員 - Pangolin", "licenseTierProfessional": "專業許可證", "licenseTierEnterprise": "企業許可證", "licenseTierPersonal": "個人許可證", "licensed": "已授權", "yes": "是", "no": "否", "sitesAdditional": "其他站點", "licenseKeys": "許可證金鑰", "sitestCountDecrease": "減少站點數量", "sitestCountIncrease": "增加站點數量", "idpManage": "管理身份提供商", "idpManageDescription": "查看和管理系統中的身份提供商", "idpDeletedDescription": "身份提供商刪除成功", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "您確定要永久刪除身份提供者嗎?", "idpMessageRemove": "這將刪除身份提供者和所有相關的配置。透過此提供者進行身份驗證的用戶將無法登錄。", "idpMessageConfirm": "要確認,請在下面輸入身份提供者的名稱。", "idpConfirmDelete": "確認刪除身份提供商", "idpDelete": "刪除身份提供商", "idp": "身份提供商", "idpSearch": "搜索身份提供者...", "idpAdd": "添加身份提供商", "idpClientIdRequired": "用戶端 ID 是必需的。", "idpClientSecretRequired": "用戶端金鑰是必需的。", "idpErrorAuthUrlInvalid": "身份驗證 URL 必須是有效的 URL。", "idpErrorTokenUrlInvalid": "令牌 URL 必須是有效的 URL。", "idpPathRequired": "標識符路徑是必需的。", "idpScopeRequired": "授權範圍是必需的。", "idpOidcDescription": "配置 OpenID 連接身份提供商", "idpCreatedDescription": "身份提供商創建成功", "idpCreate": "創建身份提供商", "idpCreateDescription": "配置用戶身份驗證的新身份提供商", "idpSeeAll": "查看所有身份提供商", "idpSettingsDescription": "配置身份提供者的基本資訊", "idpDisplayName": "此身份提供商的顯示名稱", "idpAutoProvisionUsers": "自動提供用戶", "idpAutoProvisionUsersDescription": "如果啟用,用戶將在首次登錄時自動在系統中創建,並且能夠映射用戶到角色和組織。", "licenseBadge": "EE", "idpType": "提供者類型", "idpTypeDescription": "選擇您想要配置的身份提供者類型", "idpOidcConfigure": "OAuth2/OIDC 配置", "idpOidcConfigureDescription": "配置 OAuth2/OIDC 供應商端點和憑據", "idpClientId": "用戶端ID", "idpClientIdDescription": "來自您身份提供商的 OAuth2 用戶端 ID", "idpClientSecret": "用戶端金鑰", "idpClientSecretDescription": "來自身份提供商的 OAuth2 用戶端金鑰", "idpAuthUrl": "授權 URL", "idpAuthUrlDescription": "OAuth2 授權端點的 URL", "idpTokenUrl": "令牌 URL", "idpTokenUrlDescription": "OAuth2 令牌端點的 URL", "idpOidcConfigureAlert": "重要提示", "idpOidcConfigureAlertDescription": "創建身份提供方後,您需要在其設置中配置回調 URL。回調 URL 會在創建成功後提供。", "idpToken": "令牌配置", "idpTokenDescription": "配置如何從 ID 令牌中提取用戶資訊", "idpJmespathAbout": "關於 JMESPath", "idpJmespathAboutDescription": "以下路徑使用 JMESPath 語法從 ID 令牌中提取值。", "idpJmespathAboutDescriptionLink": "了解更多 JMESPath 資訊", "idpJmespathLabel": "標識符路徑", "idpJmespathLabelDescription": "ID 令牌中用戶標識符的路徑", "idpJmespathEmailPathOptional": "信箱路徑(可選)", "idpJmespathEmailPathOptionalDescription": "ID 令牌中用戶信箱的路徑", "idpJmespathNamePathOptional": "使用者名稱路徑(可選)", "idpJmespathNamePathOptionalDescription": "ID 令牌中使用者名稱的路徑", "idpOidcConfigureScopes": "作用域(Scopes)", "idpOidcConfigureScopesDescription": "以空格分隔的 OAuth2 請求作用域列表", "idpSubmit": "創建身份提供商", "orgPolicies": "組織策略", "idpSettings": "{idpName} 設置", "idpCreateSettingsDescription": "配置身份提供商的設置", "roleMapping": "角色映射", "orgMapping": "組織映射", "orgPoliciesSearch": "搜索組織策略...", "orgPoliciesAdd": "添加組織策略", "orgRequired": "組織是必填項", "error": "錯誤", "success": "成功", "orgPolicyAddedDescription": "策略添加成功", "orgPolicyUpdatedDescription": "策略更新成功", "orgPolicyDeletedDescription": "已成功刪除策略", "defaultMappingsUpdatedDescription": "默認映射更新成功", "orgPoliciesAbout": "關於組織政策", "orgPoliciesAboutDescription": "組織策略用於根據用戶的 ID 令牌來控制對組織的訪問。 您可以指定 JMESPath 表達式來提取角色和組織資訊從 ID 令牌中提取資訊。", "orgPoliciesAboutDescriptionLink": "欲了解更多資訊,請參閱文件。", "defaultMappingsOptional": "默認映射(可選)", "defaultMappingsOptionalDescription": "當沒有為某個組織定義組織的政策時,使用默認映射。 您可以指定默認角色和組織映射回到這裡。", "defaultMappingsRole": "默認角色映射", "defaultMappingsRoleDescription": "此表達式的結果必須返回組織中定義的角色名稱作為字串。", "defaultMappingsOrg": "默認組織映射", "defaultMappingsOrgDescription": "此表達式必須返回 組織ID 或 true 才能允許用戶訪問組織。", "defaultMappingsSubmit": "保存默認映射", "orgPoliciesEdit": "編輯組織策略", "org": "組織", "orgSelect": "選擇組織", "orgSearch": "搜索", "orgNotFound": "找不到組織。", "roleMappingPathOptional": "角色映射路徑(可選)", "orgMappingPathOptional": "組織映射路徑(可選)", "orgPolicyUpdate": "更新策略", "orgPolicyAdd": "添加策略", "orgPolicyConfig": "配置組織訪問權限", "idpUpdatedDescription": "身份提供商更新成功", "redirectUrl": "重定向網址", "orgIdpRedirectUrls": "重新導向網址", "redirectUrlAbout": "關於重定向網址", "redirectUrlAboutDescription": "這是用戶在驗證後將被重定向到的URL。您需要在身份提供商設置中配置此URL。", "pangolinAuth": "認證 - Pangolin", "verificationCodeLengthRequirements": "您的驗證碼必須是 8 個字元。", "errorOccurred": "發生錯誤", "emailErrorVerify": "驗證電子郵件失敗:", "emailVerified": "電子郵件驗證成功!重定向您...", "verificationCodeErrorResend": "無法重新發送驗證碼:", "verificationCodeResend": "驗證碼已重新發送", "verificationCodeResendDescription": "我們已將驗證碼重新發送到您的電子郵件地址。請檢查您的收件箱。", "emailVerify": "驗證電子郵件", "emailVerifyDescription": "輸入驗證碼發送到您的電子郵件地址。", "verificationCode": "驗證碼", "verificationCodeEmailSent": "我們向您的電子郵件地址發送了驗證碼。", "submit": "提交", "emailVerifyResendProgress": "正在重新發送...", "emailVerifyResend": "沒有收到代碼?點擊此處重新發送", "passwordNotMatch": "密碼不匹配", "signupError": "註冊時出錯", "pangolinLogoAlt": "Pangolin 標誌", "inviteAlready": "看起來您已被邀請!", "inviteAlreadyDescription": "要接受邀請,您必須登錄或創建一個帳戶。", "signupQuestion": "已經有一個帳戶?", "login": "登錄", "resourceNotFound": "找不到資源", "resourceNotFoundDescription": "您要訪問的資源不存在。", "pincodeRequirementsLength": "PIN碼必須是 6 位數字", "pincodeRequirementsChars": "PIN 必須只包含數字", "passwordRequirementsLength": "密碼必須至少 1 個字元長", "passwordRequirementsTitle": "密碼要求:", "passwordRequirementLength": "至少 8 個字元長", "passwordRequirementUppercase": "至少一個大寫字母", "passwordRequirementLowercase": "至少一個小寫字母", "passwordRequirementNumber": "至少一個數字", "passwordRequirementSpecial": "至少一個特殊字元", "passwordRequirementsMet": "✓ 密碼滿足所有要求", "passwordStrength": "密碼強度", "passwordStrengthWeak": "弱", "passwordStrengthMedium": "中", "passwordStrengthStrong": "強", "passwordRequirements": "要求:", "passwordRequirementLengthText": "8+ 個字元", "passwordRequirementUppercaseText": "大寫字母 (A-Z)", "passwordRequirementLowercaseText": "小寫字母 (a-z)", "passwordRequirementNumberText": "數字 (0-9)", "passwordRequirementSpecialText": "特殊字元 (!@#$%...)", "passwordsDoNotMatch": "密碼不匹配", "otpEmailRequirementsLength": "OTP 必須至少 1 個字元長", "otpEmailSent": "OTP 已發送", "otpEmailSentDescription": "OTP 已經發送到您的電子郵件", "otpEmailErrorAuthenticate": "通過電子郵件身份驗證失敗", "pincodeErrorAuthenticate": "Pincode 驗證失敗", "passwordErrorAuthenticate": "密碼驗證失敗", "poweredBy": "支持者:", "authenticationRequired": "需要身份驗證", "authenticationMethodChoose": "請選擇您偏好的方式來訪問 {name}", "authenticationRequest": "您必須通過身份驗證才能訪問 {name}", "user": "用戶", "pincodeInput": "6 位數字 PIN 碼", "pincodeSubmit": "使用 PIN 登錄", "passwordSubmit": "使用密碼登錄", "otpEmailDescription": "一次性代碼將發送到此電子郵件。", "otpEmailSend": "發送一次性代碼", "otpEmail": "一次性密碼 (OTP)", "otpEmailSubmit": "提交 OTP", "backToEmail": "回到電子郵件", "noSupportKey": "伺服器當前未使用支持者金鑰,歡迎支持本項目!", "accessDenied": "訪問被拒絕", "accessDeniedDescription": "當前帳戶無權訪問此資源。如認為這是錯誤,請與管理員聯繫。", "accessTokenError": "檢查訪問令牌時出錯", "accessGranted": "已授予訪問", "accessUrlInvalid": "訪問 URL 無效", "accessGrantedDescription": "您已獲准訪問此資源,正在為您跳轉...", "accessUrlInvalidDescription": "此共享訪問URL無效。請聯絡資源所有者獲取新URL。", "tokenInvalid": "無效的令牌", "pincodeInvalid": "無效的代碼", "passwordErrorRequestReset": "請求重設失敗:", "passwordErrorReset": "重設密碼失敗:", "passwordResetSuccess": "密碼重設成功!返回登錄...", "passwordReset": "重設密碼", "passwordResetDescription": "按照步驟重設您的密碼", "passwordResetSent": "我們將發送一個驗證碼到這個電子郵件地址。", "passwordResetCode": "驗證碼", "passwordResetCodeDescription": "請檢查您的電子郵件以獲取驗證碼。", "generatePasswordResetCode": "產生密碼重設代碼", "passwordResetCodeGenerated": "密碼重設代碼已產生", "passwordResetCodeGeneratedDescription": "請將此代碼分享給使用者。他們可以用它來重設密碼。", "passwordResetUrl": "重設網址", "passwordNew": "新密碼", "passwordNewConfirm": "確認新密碼", "changePassword": "更改密碼", "changePasswordDescription": "更新您的帳戶密碼", "oldPassword": "當前密碼", "newPassword": "新密碼", "confirmNewPassword": "確認新密碼", "changePasswordError": "更改密碼失敗", "changePasswordErrorDescription": "更改您的密碼時出錯", "changePasswordSuccess": "密碼修改成功", "changePasswordSuccessDescription": "您的密碼已成功更新", "passwordExpiryRequired": "需要密碼過期", "passwordExpiryDescription": "該機構要求您每 {maxDays} 天更改一次密碼。", "changePasswordNow": "現在更改密碼", "pincodeAuth": "驗證器代碼", "pincodeSubmit2": "提交代碼", "passwordResetSubmit": "請求重設", "passwordResetAlreadyHaveCode": "輸入代碼", "passwordResetSmtpRequired": "請聯絡您的管理員", "passwordResetSmtpRequiredDescription": "需要密碼重設代碼才能重設您的密碼。請聯絡您的管理員尋求協助。", "passwordBack": "回到密碼", "loginBack": "返回登錄", "signup": "註冊", "loginStart": "登錄以開始", "idpOidcTokenValidating": "正在驗證 OIDC 令牌", "idpOidcTokenResponse": "驗證 OIDC 令牌響應", "idpErrorOidcTokenValidating": "驗證 OIDC 令牌出錯", "idpConnectingTo": "連接到{name}", "idpConnectingToDescription": "正在驗證您的身份", "idpConnectingToProcess": "正在連接...", "idpConnectingToFinished": "已連接", "idpErrorConnectingTo": "無法連接到 {name},請聯絡管理員協助處理。", "idpErrorNotFound": "找不到 IdP", "inviteInvalid": "無效邀請", "inviteInvalidDescription": "邀請連結無效。", "inviteErrorWrongUser": "邀請不是該用戶的", "inviteErrorUserNotExists": "用戶不存在。請先創建帳戶。", "inviteErrorLoginRequired": "您必須登錄才能接受邀請", "inviteErrorExpired": "邀請可能已過期", "inviteErrorRevoked": "邀請可能已被吊銷了", "inviteErrorTypo": "邀請連結中可能有一個類型", "pangolinSetup": "認證 - Pangolin", "orgNameRequired": "組織名稱是必需的", "orgIdRequired": "組織ID是必需的", "orgErrorCreate": "創建組織時出錯", "pageNotFound": "找不到頁面", "pageNotFoundDescription": "哎呀!您正在尋找的頁面不存在。", "overview": "概覽", "home": "首頁", "accessControl": "訪問控制", "settings": "設置", "usersAll": "所有用戶", "license": "許可協議", "pangolinDashboard": "儀錶板 - Pangolin", "noResults": "未找到任何結果。", "terabytes": "{count} TB", "gigabytes": "{count} GB", "megabytes": "{count} MB", "tagsEntered": "已輸入的標籤", "tagsEnteredDescription": "這些是您輸入的標籤。", "tagsWarnCannotBeLessThanZero": "最大標籤和最小標籤不能小於 0", "tagsWarnNotAllowedAutocompleteOptions": "標記不允許為每個自動完成選項", "tagsWarnInvalid": "無效的標籤,每個有效標籤", "tagWarnTooShort": "標籤 {tagText} 太短", "tagWarnTooLong": "標籤 {tagText} 太長", "tagsWarnReachedMaxNumber": "已達到允許標籤的最大數量", "tagWarnDuplicate": "未添加重複標籤 {tagText}", "supportKeyInvalid": "無效金鑰", "supportKeyInvalidDescription": "您的支持者金鑰無效。", "supportKeyValid": "有效的金鑰", "supportKeyValidDescription": "您的支持者金鑰已被驗證。感謝您的支持!", "supportKeyErrorValidationDescription": "驗證支持者金鑰失敗。", "supportKey": "支持開發和通過一個 Pangolin !", "supportKeyDescription": "購買支持者鑰匙,幫助我們繼續為社區發展 Pangolin 。 您的貢獻使我們能夠投入更多的時間來維護和添加所有人的新功能。 我們永遠不會用這個來支付牆上的功能。這與任何商業版是分開的。", "supportKeyPet": "您還可以領養並見到屬於自己的 Pangolin!", "supportKeyPurchase": "付款通過 GitHub 進行處理,之後您可以在以下位置獲取您的金鑰:", "supportKeyPurchaseLink": "我們的網站", "supportKeyPurchase2": "並在這裡兌換。", "supportKeyLearnMore": "了解更多。", "supportKeyOptions": "請選擇最適合您的選項。", "supportKetOptionFull": "完全支持者", "forWholeServer": "適用於整個伺服器", "lifetimePurchase": "終身購買", "supporterStatus": "支持者狀態", "buy": "購買", "supportKeyOptionLimited": "有限支持者", "forFiveUsers": "適用於 5 或更少用戶", "supportKeyRedeem": "兌換支持者金鑰", "supportKeyHideSevenDays": "隱藏 7 天", "supportKeyEnter": "輸入支持者金鑰", "supportKeyEnterDescription": "見到你自己的 Pangolin!", "githubUsername": "GitHub 使用者名稱", "supportKeyInput": "支持者金鑰", "supportKeyBuy": "購買支持者金鑰", "logoutError": "註銷錯誤", "signingAs": "登錄為", "serverAdmin": "伺服器管理員", "managedSelfhosted": "託管自託管", "otpEnable": "啟用雙因子認證", "otpDisable": "禁用雙因子認證", "logout": "登出", "licenseTierProfessionalRequired": "需要專業版", "licenseTierProfessionalRequiredDescription": "此功能僅在專業版可用。", "actionGetOrg": "獲取組織", "updateOrgUser": "更新組織用戶", "createOrgUser": "創建組織用戶", "actionUpdateOrg": "更新組織", "actionRemoveInvitation": "移除邀請", "actionUpdateUser": "更新用戶", "actionGetUser": "獲取用戶", "actionGetOrgUser": "獲取組織用戶", "actionListOrgDomains": "列出組織域", "actionCreateSite": "創建站點", "actionDeleteSite": "刪除站點", "actionGetSite": "獲取站點", "actionListSites": "站點列表", "actionApplyBlueprint": "應用藍圖", "actionListBlueprints": "藍圖列表", "actionGetBlueprint": "獲取藍圖", "setupToken": "設置令牌", "setupTokenDescription": "從伺服器控制台輸入設定令牌。", "setupTokenRequired": "需要設置令牌", "actionUpdateSite": "更新站點", "actionListSiteRoles": "允許站點角色列表", "actionCreateResource": "創建資源", "actionDeleteResource": "刪除資源", "actionGetResource": "獲取資源", "actionListResource": "列出資源", "actionUpdateResource": "更新資源", "actionListResourceUsers": "列出資源用戶", "actionSetResourceUsers": "設置資源用戶", "actionSetAllowedResourceRoles": "設置允許的資源角色", "actionListAllowedResourceRoles": "列出允許的資源角色", "actionSetResourcePassword": "設置資源密碼", "actionSetResourcePincode": "設置資源粉碼", "actionSetResourceEmailWhitelist": "設置資源電子郵件白名單", "actionGetResourceEmailWhitelist": "獲取資源電子郵件白名單", "actionCreateTarget": "創建目標", "actionDeleteTarget": "刪除目標", "actionGetTarget": "獲取目標", "actionListTargets": "列表目標", "actionUpdateTarget": "更新目標", "actionCreateRole": "創建角色", "actionDeleteRole": "刪除角色", "actionGetRole": "獲取角色", "actionListRole": "角色列表", "actionUpdateRole": "更新角色", "actionListAllowedRoleResources": "列表允許的角色資源", "actionInviteUser": "邀請用戶", "actionRemoveUser": "刪除用戶", "actionListUsers": "列出用戶", "actionAddUserRole": "添加用戶角色", "actionGenerateAccessToken": "生成訪問令牌", "actionDeleteAccessToken": "刪除訪問令牌", "actionListAccessTokens": "訪問令牌", "actionCreateResourceRule": "創建資源規則", "actionDeleteResourceRule": "刪除資源規則", "actionListResourceRules": "列出資源規則", "actionUpdateResourceRule": "更新資源規則", "actionListOrgs": "列出組織", "actionCheckOrgId": "檢查組織ID", "actionCreateOrg": "創建組織", "actionDeleteOrg": "刪除組織", "actionListApiKeys": "列出 API 金鑰", "actionListApiKeyActions": "列出 API 金鑰動作", "actionSetApiKeyActions": "設置 API 金鑰允許的操作", "actionCreateApiKey": "創建 API 金鑰", "actionDeleteApiKey": "刪除 API 金鑰", "actionCreateIdp": "創建 IDP", "actionUpdateIdp": "更新 IDP", "actionDeleteIdp": "刪除 IDP", "actionListIdps": "列出 IDP", "actionGetIdp": "獲取 IDP", "actionCreateIdpOrg": "創建 IDP 組織策略", "actionDeleteIdpOrg": "刪除 IDP 組織策略", "actionListIdpOrgs": "列出 IDP 組織", "actionUpdateIdpOrg": "更新 IDP 組織", "actionCreateClient": "創建用戶端", "actionDeleteClient": "刪除用戶端", "actionUpdateClient": "更新用戶端", "actionListClients": "列出用戶端", "actionGetClient": "獲取用戶端", "actionCreateSiteResource": "創建站點資源", "actionDeleteSiteResource": "刪除站點資源", "actionGetSiteResource": "獲取站點資源", "actionListSiteResources": "列出站點資源", "actionUpdateSiteResource": "更新站點資源", "actionListInvitations": "邀請列表", "actionExportLogs": "匯出日誌", "actionViewLogs": "查看日誌", "noneSelected": "未選擇", "orgNotFound2": "未找到組織。", "searchProgress": "搜索中...", "create": "創建", "orgs": "組織", "loginError": "登錄時出錯", "loginRequiredForDevice": "需要登入以驗證您的裝置。", "passwordForgot": "忘記密碼?", "otpAuth": "兩步驗證", "otpAuthDescription": "從您的身份驗證程序中輸入代碼或您的單次備份代碼。", "otpAuthSubmit": "提交代碼", "idpContinue": "或者繼續", "otpAuthBack": "返回登錄", "navbar": "導航菜單", "navbarDescription": "應用程式的主導航菜單", "navbarDocsLink": "文件", "otpErrorEnable": "無法啟用 2FA", "otpErrorEnableDescription": "啟用 2FA 時出錯", "otpSetupCheckCode": "請輸入您的 6 位數字代碼", "otpSetupCheckCodeRetry": "無效的代碼。請重試。", "otpSetup": "啟用兩步驗證", "otpSetupDescription": "用額外的保護層來保護您的帳戶", "otpSetupScanQr": "用您的身份驗證程序掃描此二維碼或手動輸入金鑰:", "otpSetupSecretCode": "驗證器代碼", "otpSetupSuccess": "啟用兩步驗證", "otpSetupSuccessStoreBackupCodes": "您的帳戶現在更加安全。不要忘記保存您的備份代碼。", "otpErrorDisable": "無法禁用 2FA", "otpErrorDisableDescription": "禁用 2FA 時出錯", "otpRemove": "禁用兩步驗證", "otpRemoveDescription": "為您的帳戶禁用兩步驗證", "otpRemoveSuccess": "雙重身份驗證已禁用", "otpRemoveSuccessMessage": "您的帳戶已禁用雙重身份驗證。您可以隨時再次啟用它。", "otpRemoveSubmit": "禁用兩步驗證", "paginator": "第 {current} 頁,共 {last} 頁", "paginatorToFirst": "轉到第一頁", "paginatorToPrevious": "轉到上一頁", "paginatorToNext": "轉到下一頁", "paginatorToLast": "轉到最後一頁", "copyText": "複製文本", "copyTextFailed": "複製文本失敗: ", "copyTextClipboard": "複製到剪貼簿", "inviteErrorInvalidConfirmation": "無效確認", "passwordRequired": "必須填寫密碼", "allowAll": "允許所有", "permissionsAllowAll": "允許所有權限", "githubUsernameRequired": "必須填寫 GitHub 使用者名稱", "supportKeyRequired": "必須填寫支持者金鑰", "passwordRequirementsChars": "密碼至少需要 8 個字元", "language": "語言", "verificationCodeRequired": "必須輸入代碼", "userErrorNoUpdate": "沒有要更新的用戶", "siteErrorNoUpdate": "沒有要更新的站點", "resourceErrorNoUpdate": "沒有可更新的資源", "authErrorNoUpdate": "沒有要更新的身份驗證資訊", "orgErrorNoUpdate": "沒有要更新的組織", "orgErrorNoProvided": "未提供組織", "apiKeysErrorNoUpdate": "沒有要更新的 API 金鑰", "sidebarOverview": "概覽", "sidebarHome": "首頁", "sidebarSites": "站點", "sidebarResources": "資源", "sidebarProxyResources": "公開", "sidebarClientResources": "私有", "sidebarAccessControl": "訪問控制", "sidebarLogsAndAnalytics": "日誌與分析", "sidebarUsers": "用戶", "sidebarAdmin": "管理員", "sidebarInvitations": "邀請", "sidebarRoles": "角色", "sidebarShareableLinks": "分享連結", "sidebarApiKeys": "API 金鑰", "sidebarSettings": "設置", "sidebarAllUsers": "所有用戶", "sidebarIdentityProviders": "身份提供商", "sidebarLicense": "證書", "sidebarClients": "用戶端", "sidebarUserDevices": "使用者", "sidebarMachineClients": "機器", "sidebarDomains": "域", "sidebarGeneral": "管理", "sidebarLogAndAnalytics": "日誌與分析", "sidebarBluePrints": "藍圖", "sidebarOrganization": "組織", "sidebarLogsAnalytics": "分析", "blueprints": "藍圖", "blueprintsDescription": "應用聲明配置並查看先前運行的", "blueprintAdd": "添加藍圖", "blueprintGoBack": "查看所有藍圖", "blueprintCreate": "創建藍圖", "blueprintCreateDescription2": "按照下面的步驟創建和應用新的藍圖", "blueprintDetails": "藍圖詳細資訊", "blueprintDetailsDescription": "查看應用藍圖的結果和發生的任何錯誤", "blueprintInfo": "藍圖資訊", "message": "留言", "blueprintContentsDescription": "定義描述您基礎設施的 YAML 內容", "blueprintErrorCreateDescription": "應用藍圖時出錯", "blueprintErrorCreate": "創建藍圖時出錯", "searchBlueprintProgress": "搜索藍圖...", "appliedAt": "應用於", "source": "來源", "contents": "目錄", "parsedContents": "解析內容 (只讀)", "enableDockerSocket": "啟用 Docker 藍圖", "enableDockerSocketDescription": "啟用 Docker Socket 標籤擦除藍圖標籤。套接字路徑必須提供給新的。", "enableDockerSocketLink": "了解更多", "viewDockerContainers": "查看停靠容器", "containersIn": "{siteName} 中的容器", "selectContainerDescription": "選擇任何容器作為目標的主機名。點擊埠使用埠。", "containerName": "名稱", "containerImage": "圖片", "containerState": "狀態", "containerNetworks": "網路", "containerHostnameIp": "主機名/IP", "containerLabels": "標籤", "containerLabelsCount": "{count, plural, other {# 標籤}}", "containerLabelsTitle": "容器標籤", "containerLabelEmpty": "<為空>", "containerPorts": "埠", "containerPortsMore": "+{count} 更多", "containerActions": "行動", "select": "選擇", "noContainersMatchingFilters": "沒有找到匹配當前過濾器的容器。", "showContainersWithoutPorts": "顯示沒有埠的容器", "showStoppedContainers": "顯示已停止的容器", "noContainersFound": "未找到容器。請確保 Docker 容器正在運行。", "searchContainersPlaceholder": "在 {count} 個容器中搜索...", "searchResultsCount": "{count, plural, other {# 個結果}}", "filters": "篩選器", "filterOptions": "過濾器選項", "filterPorts": "埠", "filterStopped": "已停止", "clearAllFilters": "清除所有過濾器", "columns": "列", "toggleColumns": "切換列", "refreshContainersList": "刷新容器列表", "searching": "搜索中...", "noContainersFoundMatching": "未找到與 \"{filter}\" 匹配的容器。", "light": "淺色", "dark": "深色", "system": "系統", "theme": "主題", "subnetRequired": "子網是必填項", "initialSetupTitle": "初始伺服器設置", "initialSetupDescription": "創建初始伺服器管理員帳戶。 只能存在一個伺服器管理員。 您可以隨時更改這些憑據。", "createAdminAccount": "創建管理員帳戶", "setupErrorCreateAdmin": "創建伺服器管理員帳戶時發生錯誤。", "certificateStatus": "證書狀態", "loading": "載入中", "restart": "重啟", "domains": "域", "domainsDescription": "管理您的組織域", "domainsSearch": "搜索域...", "domainAdd": "添加域", "domainAddDescription": "在您的組織中註冊新域", "domainCreate": "創建域", "domainCreatedDescription": "域創建成功", "domainDeletedDescription": "成功刪除域", "domainQuestionRemove": "您確定要從您的帳戶中刪除域名嗎?", "domainMessageRemove": "移除後,該域將不再與您的帳戶關聯。", "domainConfirmDelete": "確認刪除域", "domainDelete": "刪除域", "domain": "域", "selectDomainTypeNsName": "域委派(NS)", "selectDomainTypeNsDescription": "此域及其所有子域。當您希望控制整個域區域時使用此選項。", "selectDomainTypeCnameName": "單個域(CNAME)", "selectDomainTypeCnameDescription": "僅此特定域。用於單個子域或特定域條目。", "selectDomainTypeWildcardName": "通配符域", "selectDomainTypeWildcardDescription": "此域名及其子域名。", "domainDelegation": "單個域", "selectType": "選擇一個類型", "actions": "操作", "refresh": "刷新", "refreshError": "刷新數據失敗", "verified": "已驗證", "pending": "待定", "sidebarBilling": "計費", "billing": "計費", "orgBillingDescription": "管理您的帳單資訊和訂閱", "github": "GitHub", "pangolinHosted": "Pangolin 託管", "fossorial": "Fossorial", "completeAccountSetup": "完成帳戶設定", "completeAccountSetupDescription": "設置您的密碼以開始", "accountSetupSent": "我們將發送帳號設定代碼到該電子郵件地址。", "accountSetupCode": "設置代碼", "accountSetupCodeDescription": "請檢查您的信箱以獲取設置代碼。", "passwordCreate": "創建密碼", "passwordCreateConfirm": "確認密碼", "accountSetupSubmit": "發送設置代碼", "completeSetup": "完成設置", "accountSetupSuccess": "帳號設定完成!歡迎來到 Pangolin!", "documentation": "文件", "saveAllSettings": "保存所有設置", "saveResourceTargets": "儲存目標", "saveResourceHttp": "儲存代理設定", "saveProxyProtocol": "儲存代理協定設定", "settingsUpdated": "設置已更新", "settingsUpdatedDescription": "所有設置已成功更新", "settingsErrorUpdate": "設置更新失敗", "settingsErrorUpdateDescription": "更新設置時發生錯誤", "sidebarCollapse": "摺疊", "sidebarExpand": "展開", "productUpdateMoreInfo": "還有 {noOfUpdates} 項更新", "productUpdateInfo": "{noOfUpdates} 項更新", "productUpdateWhatsNew": "新功能", "productUpdateTitle": "產品更新", "productUpdateEmpty": "沒有更新", "dismissAll": "全部關閉", "pangolinUpdateAvailable": "有可用更新", "pangolinUpdateAvailableInfo": "版本 {version} 已準備好安裝", "pangolinUpdateAvailableReleaseNotes": "查看發行說明", "newtUpdateAvailable": "更新可用", "newtUpdateAvailableInfo": "新版本的 Newt 已可用。請更新到最新版本以獲得最佳體驗。", "domainPickerEnterDomain": "域名", "domainPickerPlaceholder": "example.com", "domainPickerDescription": "輸入資源的完整域名以查看可用選項。", "domainPickerDescriptionSaas": "輸入完整域名、子域或名稱以查看可用選項。", "domainPickerTabAll": "所有", "domainPickerTabOrganization": "組織", "domainPickerTabProvided": "提供的", "domainPickerSortAsc": "A-Z", "domainPickerSortDesc": "Z-A", "domainPickerCheckingAvailability": "檢查可用性...", "domainPickerNoMatchingDomains": "未找到匹配的域名。嘗試不同的域名或檢查您組織的域名設置。", "domainPickerOrganizationDomains": "組織域", "domainPickerProvidedDomains": "提供的域", "domainPickerSubdomain": "子域:{subdomain}", "domainPickerNamespace": "命名空間:{namespace}", "domainPickerShowMore": "顯示更多", "regionSelectorTitle": "選擇區域", "regionSelectorInfo": "選擇區域以幫助提升您所在地的性能。您不必與伺服器在相同的區域。", "regionSelectorPlaceholder": "選擇一個區域", "regionSelectorComingSoon": "即將推出", "billingLoadingSubscription": "正在載入訂閱...", "billingFreeTier": "免費層", "billingWarningOverLimit": "警告:您已超出一個或多個使用限制。在您修改訂閱或調整使用情況之前,您的站點將無法連接。", "billingUsageLimitsOverview": "使用限制概覽", "billingMonitorUsage": "監控您的使用情況以對比已配置的限制。如需提高限制請聯絡我們 support@pangolin.net。", "billingDataUsage": "數據使用情況", "billingOnlineTime": "站點在線時間", "billingUsers": "活躍用戶", "billingDomains": "活躍域", "billingRemoteExitNodes": "活躍自託管節點", "billingNoLimitConfigured": "未配置限制", "billingEstimatedPeriod": "估計結算週期", "billingIncludedUsage": "包含的使用量", "billingIncludedUsageDescription": "您當前訂閱計劃中包含的使用量", "billingFreeTierIncludedUsage": "免費層使用額度", "billingIncluded": "包含", "billingEstimatedTotal": "預計總額:", "billingNotes": "備註", "billingEstimateNote": "這是根據您當前使用情況的估算。", "billingActualChargesMayVary": "實際費用可能會有變化。", "billingBilledAtEnd": "您將在結算週期結束時被計費。", "billingModifySubscription": "修改訂閱", "billingStartSubscription": "開始訂閱", "billingRecurringCharge": "週期性收費", "billingManageSubscriptionSettings": "管理您的訂閱設置和偏好", "billingNoActiveSubscription": "您沒有活躍的訂閱。開始訂閱以增加使用限制。", "billingFailedToLoadSubscription": "無法載入訂閱", "billingFailedToLoadUsage": "無法載入使用情況", "billingFailedToGetCheckoutUrl": "無法獲取結帳網址", "billingPleaseTryAgainLater": "請稍後再試。", "billingCheckoutError": "結帳錯誤", "billingFailedToGetPortalUrl": "無法獲取門戶網址", "billingPortalError": "門戶錯誤", "billingDataUsageInfo": "當連接到雲端時,您將為透過安全隧道傳輸的所有數據收取費用。 這包括您所有站點的進出流量。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取數據。", "billingOnlineTimeInfo": "您要根據您的網站連接到雲端的時間長短收取費用。 例如,44,640 分鐘等於一個 24/7 全月運行的網站。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取費用。", "billingUsersInfo": "根據您組織中的活躍用戶數量收費。按日計算帳單。", "billingDomainInfo": "根據組織中活躍域的數量收費。按日計算帳單。", "billingRemoteExitNodesInfo": "根據您組織中已管理節點的數量收費。按日計算帳單。", "domainNotFound": "域未找到", "domainNotFoundDescription": "此資源已禁用,因為該域不再在我們的系統中存在。請為此資源設置一個新域。", "failed": "失敗", "createNewOrgDescription": "創建一個新組織", "organization": "組織", "port": "埠", "securityKeyManage": "管理安全金鑰", "securityKeyDescription": "添加或刪除用於無密碼認證的安全金鑰", "securityKeyRegister": "註冊新的安全金鑰", "securityKeyList": "您的安全金鑰", "securityKeyNone": "尚未註冊安全金鑰", "securityKeyNameRequired": "名稱為必填項", "securityKeyRemove": "刪除", "securityKeyLastUsed": "上次使用:{date}", "securityKeyNameLabel": "名稱", "securityKeyRegisterSuccess": "安全金鑰註冊成功", "securityKeyRegisterError": "註冊安全金鑰失敗", "securityKeyRemoveSuccess": "安全金鑰刪除成功", "securityKeyRemoveError": "刪除安全金鑰失敗", "securityKeyLoadError": "載入安全金鑰失敗", "securityKeyLogin": "使用安全金鑰繼續", "securityKeyAuthError": "使用安全金鑰認證失敗", "securityKeyRecommendation": "考慮在其他設備上註冊另一個安全金鑰,以確保不會被鎖定在您的帳戶之外。", "registering": "註冊中...", "securityKeyPrompt": "請使用您的安全金鑰驗證身份。確保您的安全金鑰已連接並準備好。", "securityKeyBrowserNotSupported": "您的瀏覽器不支持安全金鑰。請使用像 Chrome、Firefox 或 Safari 這樣的現代瀏覽器。", "securityKeyPermissionDenied": "請允許訪問您的安全金鑰以繼續登錄。", "securityKeyRemovedTooQuickly": "請保持您的安全金鑰連接,直到登錄過程完成。", "securityKeyNotSupported": "您的安全金鑰可能不相容。請嘗試不同的安全金鑰。", "securityKeyUnknownError": "使用安全金鑰時出現問題。請再試一次。", "twoFactorRequired": "註冊安全金鑰需要兩步驗證。", "twoFactor": "兩步驗證", "twoFactorAuthentication": "兩步驗證", "twoFactorDescription": "這個組織需要雙重身份驗證。", "enableTwoFactor": "啟用兩步驗證", "organizationSecurityPolicy": "組織安全政策", "organizationSecurityPolicyDescription": "此機構擁有安全要求,您必須先滿足才能訪問", "securityRequirements": "安全要求", "allRequirementsMet": "已滿足所有要求", "completeRequirementsToContinue": "完成下面的要求以繼續訪問此組織", "youCanNowAccessOrganization": "您現在可以訪問此組織", "reauthenticationRequired": "會話長度", "reauthenticationDescription": "該機構要求您每 {maxDays} 天登錄一次。", "reauthenticationDescriptionHours": "該機構要求您每 {maxHours} 小時登錄一次。", "reauthenticateNow": "再次登錄", "adminEnabled2FaOnYourAccount": "管理員已為 {email} 啟用兩步驗證。請完成設置以繼續。", "securityKeyAdd": "添加安全金鑰", "securityKeyRegisterTitle": "註冊新安全金鑰", "securityKeyRegisterDescription": "連接您的安全金鑰並輸入名稱以便識別", "securityKeyTwoFactorRequired": "要求兩步驗證", "securityKeyTwoFactorDescription": "請輸入你的兩步驗證代碼以註冊安全金鑰", "securityKeyTwoFactorRemoveDescription": "請輸入你的兩步驗證代碼以移除安全金鑰", "securityKeyTwoFactorCode": "雙因素代碼", "securityKeyRemoveTitle": "移除安全金鑰", "securityKeyRemoveDescription": "輸入您的密碼以移除安全金鑰 \"{name}\"", "securityKeyNoKeysRegistered": "沒有註冊安全金鑰", "securityKeyNoKeysDescription": "添加安全金鑰以加強您的帳戶安全", "createDomainRequired": "必須輸入域", "createDomainAddDnsRecords": "添加 DNS 記錄", "createDomainAddDnsRecordsDescription": "將以下 DNS 記錄添加到您的域名提供商以完成設置。", "createDomainNsRecords": "NS 記錄", "createDomainRecord": "記錄", "createDomainType": "類型:", "createDomainName": "名稱:", "createDomainValue": "值:", "createDomainCnameRecords": "CNAME 記錄", "createDomainARecords": "A記錄", "createDomainRecordNumber": "記錄 {number}", "createDomainTxtRecords": "TXT 記錄", "createDomainSaveTheseRecords": "保存這些記錄", "createDomainSaveTheseRecordsDescription": "務必保存這些 DNS 記錄,因為您將無法再次查看它們。", "createDomainDnsPropagation": "DNS 傳播", "createDomainDnsPropagationDescription": "DNS 更改可能需要一些時間才能在網路上傳播。這可能需要從幾分鐘到 48 小時,具體取決於您的 DNS 提供商和 TTL 設置。", "resourcePortRequired": "非 HTTP 資源必須輸入埠號", "resourcePortNotAllowed": "HTTP 資源不應設置埠號", "billingPricingCalculatorLink": "價格計算機", "signUpTerms": { "IAgreeToThe": "我同意", "termsOfService": "服務條款", "and": "和", "privacyPolicy": "隱私政策" }, "signUpMarketing": { "keepMeInTheLoop": "透過電子郵件接收新聞、更新和新功能通知。" }, "siteRequired": "需要站點。", "olmTunnel": "Olm 隧道", "olmTunnelDescription": "使用 Olm 進行用戶端連接", "errorCreatingClient": "創建用戶端出錯", "clientDefaultsNotFound": "未找到用戶端預設值", "createClient": "創建用戶端", "createClientDescription": "創建一個新用戶端來連接您的站點", "seeAllClients": "查看所有用戶端", "clientInformation": "用戶端資訊", "clientNamePlaceholder": "用戶端名稱", "address": "地址", "subnetPlaceholder": "子網", "addressDescription": "此用戶端將用於連接的地址", "selectSites": "選擇站點", "sitesDescription": "用戶端將與所選站點進行連接", "clientInstallOlm": "安裝 Olm", "clientInstallOlmDescription": "在您的系統上運行 Olm", "clientOlmCredentials": "Olm 憑據", "clientOlmCredentialsDescription": "這是 Olm 伺服器的身份驗證方式", "olmEndpoint": "Olm 端點", "olmId": "Olm ID", "olmSecretKey": "Olm 私鑰", "clientCredentialsSave": "保存您的憑據", "clientCredentialsSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全位置。", "generalSettingsDescription": "配置此用戶端的常規設置", "clientUpdated": "用戶端已更新", "clientUpdatedDescription": "用戶端已更新。", "clientUpdateFailed": "更新用戶端失敗", "clientUpdateError": "更新用戶端時出錯。", "sitesFetchFailed": "獲取站點失敗", "sitesFetchError": "獲取站點時出錯。", "olmErrorFetchReleases": "獲取 Olm 發布版本時出錯。", "olmErrorFetchLatest": "獲取最新 Olm 發布版本時出錯。", "enterCidrRange": "輸入 CIDR 範圍", "resourceEnableProxy": "啟用公共代理", "resourceEnableProxyDescription": "啟用到此資源的公共代理。這允許外部網路通過開放埠訪問資源。需要 Traefik 配置。", "externalProxyEnabled": "外部代理已啟用", "addNewTarget": "添加新目標", "targetsList": "目標列表", "advancedMode": "高級模式", "advancedSettings": "進階設定", "targetErrorDuplicateTargetFound": "找到重複的目標", "healthCheckHealthy": "正常", "healthCheckUnhealthy": "不正常", "healthCheckUnknown": "未知", "healthCheck": "健康檢查", "configureHealthCheck": "配置健康檢查", "configureHealthCheckDescription": "為 {target} 設置健康監控", "enableHealthChecks": "啟用健康檢查", "enableHealthChecksDescription": "監視此目標的健康狀況。如果需要,您可以監視一個不同的終點。", "healthScheme": "方法", "healthSelectScheme": "選擇方法", "healthCheckPortInvalid": "健康檢查連接埠必須介於 1 到 65535 之間", "healthCheckPath": "路徑", "healthHostname": "IP / 主機", "healthPort": "埠", "healthCheckPathDescription": "用於檢查健康狀態的路徑。", "healthyIntervalSeconds": "正常間隔", "unhealthyIntervalSeconds": "不正常間隔", "IntervalSeconds": "正常間隔", "timeoutSeconds": "超時", "timeIsInSeconds": "時間以秒為單位", "retryAttempts": "重試次數", "expectedResponseCodes": "期望響應代碼", "expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。", "customHeaders": "自訂 Headers", "customHeadersDescription": "Header 斷行分隔:Header 名稱:值", "headersValidationError": "Header 必須是格式:Header 名稱:值。", "saveHealthCheck": "保存健康檢查", "healthCheckSaved": "健康檢查已保存", "healthCheckSavedDescription": "健康檢查配置已成功保存。", "healthCheckError": "健康檢查錯誤", "healthCheckErrorDescription": "保存健康檢查配置時出錯", "healthCheckPathRequired": "健康檢查路徑為必填項", "healthCheckMethodRequired": "HTTP 方法為必填項", "healthCheckIntervalMin": "檢查間隔必須至少為 5 秒", "healthCheckTimeoutMin": "超時必須至少為 1 秒", "healthCheckRetryMin": "重試次數必須至少為 1 次", "httpMethod": "HTTP 方法", "selectHttpMethod": "選擇 HTTP 方法", "domainPickerSubdomainLabel": "子域名", "domainPickerBaseDomainLabel": "根域名", "domainPickerSearchDomains": "搜索域名...", "domainPickerNoDomainsFound": "未找到域名", "domainPickerLoadingDomains": "載入域名...", "domainPickerSelectBaseDomain": "選擇根域名...", "domainPickerNotAvailableForCname": "不適用於 CNAME 域", "domainPickerEnterSubdomainOrLeaveBlank": "輸入子域名或留空以使用根域名。", "domainPickerEnterSubdomainToSearch": "輸入一個子域名以搜索並從可用免費域名中選擇。", "domainPickerFreeDomains": "免費域名", "domainPickerSearchForAvailableDomains": "搜索可用域名", "domainPickerNotWorkSelfHosted": "注意:自託管實例當前不提供免費的域名。", "resourceDomain": "域名", "resourceEditDomain": "編輯域名", "siteName": "站點名稱", "proxyPort": "埠", "resourcesTableProxyResources": "代理資源", "resourcesTableClientResources": "用戶端資源", "resourcesTableNoProxyResourcesFound": "未找到代理資源。", "resourcesTableNoInternalResourcesFound": "未找到內部資源。", "resourcesTableDestination": "目標", "resourcesTableAlias": "別名", "resourcesTableClients": "用戶端", "resourcesTableAndOnlyAccessibleInternally": "且僅在與用戶端連接時可內部訪問。", "resourcesTableNoTargets": "無目標", "resourcesTableHealthy": "健康", "resourcesTableDegraded": "降級", "resourcesTableOffline": "離線", "resourcesTableUnknown": "未知", "resourcesTableNotMonitored": "未監控", "editInternalResourceDialogEditClientResource": "編輯用戶端資源", "editInternalResourceDialogUpdateResourceProperties": "更新 {resourceName} 的資源屬性和目標配置。", "editInternalResourceDialogResourceProperties": "資源屬性", "editInternalResourceDialogName": "名稱", "editInternalResourceDialogProtocol": "協議", "editInternalResourceDialogSitePort": "站點埠", "editInternalResourceDialogTargetConfiguration": "目標配置", "editInternalResourceDialogCancel": "取消", "editInternalResourceDialogSaveResource": "保存資源", "editInternalResourceDialogSuccess": "成功", "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "內部資源更新成功", "editInternalResourceDialogError": "錯誤", "editInternalResourceDialogFailedToUpdateInternalResource": "更新內部資源失敗", "editInternalResourceDialogNameRequired": "名稱為必填項", "editInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", "editInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", "editInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", "editInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", "editInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", "editInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", "editInternalResourceDialogPortModeRequired": "連接埠模式需要協定、代理連接埠和目標連接埠", "editInternalResourceDialogMode": "模式", "editInternalResourceDialogModePort": "連接埠", "editInternalResourceDialogModeHost": "主機", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogDestination": "目的地", "editInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。", "editInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名稱位址。", "editInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。", "editInternalResourceDialogAlias": "別名", "editInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。", "createInternalResourceDialogNoSitesAvailable": "暫無可用站點", "createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一個子網的 Newt 站點來創建內部資源。", "createInternalResourceDialogClose": "關閉", "createInternalResourceDialogCreateClientResource": "創建用戶端資源", "createInternalResourceDialogCreateClientResourceDescription": "創建一個新資源,該資源將可供連接到所選站點的用戶端訪問。", "createInternalResourceDialogResourceProperties": "資源屬性", "createInternalResourceDialogName": "名稱", "createInternalResourceDialogSite": "站點", "selectSite": "選擇站點...", "noSitesFound": "找不到站點。", "createInternalResourceDialogProtocol": "協議", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", "createInternalResourceDialogSitePort": "站點埠", "createInternalResourceDialogSitePortDescription": "使用此埠在連接到用戶端時訪問站點上的資源。", "createInternalResourceDialogTargetConfiguration": "目標配置", "createInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名地址。", "createInternalResourceDialogDestinationPortDescription": "資源在目標 IP 上可訪問的埠。", "createInternalResourceDialogCancel": "取消", "createInternalResourceDialogCreateResource": "創建資源", "createInternalResourceDialogSuccess": "成功", "createInternalResourceDialogInternalResourceCreatedSuccessfully": "內部資源創建成功", "createInternalResourceDialogError": "錯誤", "createInternalResourceDialogFailedToCreateInternalResource": "創建內部資源失敗", "createInternalResourceDialogNameRequired": "名稱為必填項", "createInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", "createInternalResourceDialogPleaseSelectSite": "請選擇一個站點", "createInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", "createInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", "createInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", "createInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", "createInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", "createInternalResourceDialogPortModeRequired": "連接埠模式需要協定、代理連接埠和目標連接埠", "createInternalResourceDialogMode": "模式", "createInternalResourceDialogModePort": "連接埠", "createInternalResourceDialogModeHost": "主機", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogDestination": "目的地", "createInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。", "createInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。", "createInternalResourceDialogAlias": "別名", "createInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。", "siteConfiguration": "配置", "siteAcceptClientConnections": "接受用戶端連接", "siteAcceptClientConnectionsDescription": "允許其他設備透過此 Newt 實例使用用戶端作為閘道器連接。", "siteAddress": "站點地址", "siteAddressDescription": "指定主機的 IP 位址以供用戶端連接。這是 Pangolin 網路中站點的內部地址,供用戶端訪問。必須在 Org 子網內。", "siteNameDescription": "站點的顯示名稱,可以稍後更改。", "autoLoginExternalIdp": "自動使用外部 IDP 登錄", "autoLoginExternalIdpDescription": "立即將用戶重定向到外部 IDP 進行身份驗證。", "selectIdp": "選擇 IDP", "selectIdpPlaceholder": "選擇一個 IDP...", "selectIdpRequired": "在啟用自動登錄時,請選擇一個 IDP。", "autoLoginTitle": "重定向中", "autoLoginDescription": "正在將您重定向到外部身份提供商進行身份驗證。", "autoLoginProcessing": "準備身份驗證...", "autoLoginRedirecting": "重定向到登錄...", "autoLoginError": "自動登錄錯誤", "autoLoginErrorNoRedirectUrl": "未從身份提供商收到重定向 URL。", "autoLoginErrorGeneratingUrl": "生成身份驗證 URL 失敗。", "remoteExitNodeManageRemoteExitNodes": "遠程節點", "remoteExitNodeDescription": "自我主機一個或多個遠程節點來擴展您的網路連接並減少對雲的依賴性", "remoteExitNodes": "節點", "searchRemoteExitNodes": "搜索節點...", "remoteExitNodeAdd": "添加節點", "remoteExitNodeErrorDelete": "刪除節點時出錯", "remoteExitNodeQuestionRemove": "您確定要從組織中刪除該節點嗎?", "remoteExitNodeMessageRemove": "一旦刪除,該節點將不再能夠訪問。", "remoteExitNodeConfirmDelete": "確認刪除節點", "remoteExitNodeDelete": "刪除節點", "sidebarRemoteExitNodes": "遠程節點", "remoteExitNodeId": "ID", "remoteExitNodeSecretKey": "密鑰", "remoteExitNodeCreate": { "title": "創建節點", "description": "創建一個新節點來擴展您的網路連接", "viewAllButton": "查看所有節點", "strategy": { "title": "創建策略", "description": "選擇此選項以手動配置您的節點或生成新憑據。", "adopt": { "title": "採納節點", "description": "如果您已經擁有該節點的憑據,請選擇此項。" }, "generate": { "title": "生成金鑰", "description": "如果您想為節點生成新金鑰,請選擇此選項" } }, "adopt": { "title": "採納現有節點", "description": "輸入您想要採用的現有節點的憑據", "nodeIdLabel": "節點 ID", "nodeIdDescription": "您想要採用的現有節點的 ID", "secretLabel": "金鑰", "secretDescription": "現有節點的秘密金鑰", "submitButton": "採用節點" }, "generate": { "title": "生成的憑據", "description": "使用這些生成的憑據來配置您的節點", "nodeIdTitle": "節點 ID", "secretTitle": "金鑰", "saveCredentialsTitle": "將憑據添加到配置中", "saveCredentialsDescription": "將這些憑據添加到您的自託管 Pangolin 節點設定檔中以完成連接。", "submitButton": "創建節點" }, "validation": { "adoptRequired": "在通過現有節點時需要節點ID和金鑰" }, "errors": { "loadDefaultsFailed": "無法載入預設值", "defaultsNotLoaded": "預設值未載入", "createFailed": "創建節點失敗" }, "success": { "created": "節點創建成功" } }, "remoteExitNodeSelection": "節點選擇", "remoteExitNodeSelectionDescription": "為此本地站點選擇要路由流量的節點", "remoteExitNodeRequired": "必須為本地站點選擇節點", "noRemoteExitNodesAvailable": "無可用節點", "noRemoteExitNodesAvailableDescription": "此組織沒有可用的節點。首先創建一個節點來使用本地站點。", "exitNode": "出口節點", "country": "國家", "rulesMatchCountry": "當前基於源 IP", "managedSelfHosted": { "title": "託管自託管", "description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能", "introTitle": "託管式自架 Pangolin", "introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。", "introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 — — 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:", "benefitSimplerOperations": { "title": "簡單的操作", "description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。" }, "benefitAutomaticUpdates": { "title": "自動更新", "description": "雲儀錶板快速演化,所以您可以獲得新的功能和錯誤修復,而不必每次手動拉取新的容器。" }, "benefitLessMaintenance": { "title": "減少維護時間", "description": "沒有要管理的資料庫遷移、備份或額外的基礎設施。我們在雲端處理這個問題。" }, "benefitCloudFailover": { "title": "雲端故障轉移", "description": "如果您的節點發生故障,您的隧道可以暫時故障轉移到我們的雲端存取點,直到您將節點恢復線上狀態。" }, "benefitHighAvailability": { "title": "高可用率(PoPs)", "description": "您還可以將多個節點添加到您的帳戶中以獲取冗餘和更好的性能。" }, "benefitFutureEnhancements": { "title": "將來的改進", "description": "我們正在計劃添加更多的分析、警報和管理工具,使你的部署更加有力。" }, "docsAlert": { "text": "在我們中更多地了解管理下的自託管選項", "documentation": "文件" }, "convertButton": "將此節點轉換為管理自託管的" }, "internationaldomaindetected": "檢測到國際域", "willbestoredas": "儲存為:", "roleMappingDescription": "確定當用戶啟用自動配送時如何分配他們的角色。", "selectRole": "選擇角色", "roleMappingExpression": "表達式", "selectRolePlaceholder": "選擇角色", "selectRoleDescription": "選擇一個角色,從此身份提供商分配給所有用戶", "roleMappingExpressionDescription": "輸入一個 JMESPath 表達式來從 ID 令牌提取角色資訊", "idpTenantIdRequired": "租戶 ID 是必需的", "invalidValue": "無效的值", "idpTypeLabel": "身份提供者類型", "roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'", "idpGoogleConfiguration": "Google 配置", "idpGoogleConfigurationDescription": "配置您的 Google OAuth2 憑據", "idpGoogleClientIdDescription": "您的 Google OAuth2 用戶端 ID", "idpGoogleClientSecretDescription": "您的 Google OAuth2 用戶端金鑰", "idpAzureConfiguration": "Azure Entra ID 配置", "idpAzureConfigurationDescription": "配置您的 Azure Entra ID OAuth2 憑據", "idpTenantId": "租戶 ID", "idpTenantIdPlaceholder": "您的租戶 ID", "idpAzureTenantIdDescription": "您的 Azure 租戶ID (在 Azure Active Directory 概覽中發現)", "idpAzureClientIdDescription": "您的 Azure 應用程式註冊用戶端 ID", "idpAzureClientSecretDescription": "您的 Azure 應用程式註冊用戶端金鑰", "idpGoogleTitle": "Google", "idpGoogleAlt": "Google", "idpAzureTitle": "Azure Entra ID", "idpAzureAlt": "Azure", "idpGoogleConfigurationTitle": "Google 配置", "idpAzureConfigurationTitle": "Azure Entra ID 配置", "idpTenantIdLabel": "租戶 ID", "idpAzureClientIdDescription2": "您的 Azure 應用程式註冊用戶端 ID", "idpAzureClientSecretDescription2": "您的 Azure 應用程式註冊用戶端金鑰", "idpGoogleDescription": "Google OAuth2/OIDC 提供商", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 提供者", "subnet": "子網", "subnetDescription": "此組織網路配置的子網。", "customDomain": "自訂網域", "authPage": "認證頁面", "authPageDescription": "配置您的組織認證頁面", "authPageDomain": "認證頁面域", "authPageBranding": "自訂品牌", "authPageBrandingDescription": "設定此組織驗證頁面上顯示的品牌", "authPageBrandingUpdated": "驗證頁面品牌更新成功", "authPageBrandingRemoved": "驗證頁面品牌移除成功", "authPageBrandingRemoveTitle": "移除驗證頁面品牌", "authPageBrandingQuestionRemove": "您確定要移除驗證頁面的品牌嗎?", "authPageBrandingDeleteConfirm": "確認刪除品牌", "brandingLogoURL": "Logo 網址", "brandingPrimaryColor": "主要顏色", "brandingLogoWidth": "寬度 (px)", "brandingLogoHeight": "高度 (px)", "brandingOrgTitle": "組織驗證頁面標題", "brandingOrgDescription": "{orgName} 將被替換為組織名稱", "brandingOrgSubtitle": "組織驗證頁面副標題", "brandingResourceTitle": "資源驗證頁面標題", "brandingResourceSubtitle": "資源驗證頁面副標題", "brandingResourceDescription": "{resourceName} 將被替換為組織名稱", "saveAuthPageDomain": "儲存網域", "saveAuthPageBranding": "儲存品牌", "removeAuthPageBranding": "移除品牌", "noDomainSet": "沒有域設置", "changeDomain": "更改域", "selectDomain": "選擇域", "restartCertificate": "重新啟動證書", "editAuthPageDomain": "編輯認證頁面域", "setAuthPageDomain": "設置認證頁面域", "failedToFetchCertificate": "獲取證書失敗", "failedToRestartCertificate": "重新啟動證書失敗", "addDomainToEnableCustomAuthPages": "為您的組織添加域名以啟用自訂認證頁面", "selectDomainForOrgAuthPage": "選擇組織認證頁面的域", "domainPickerProvidedDomain": "提供的域", "domainPickerFreeProvidedDomain": "免費提供的域", "domainPickerVerified": "已驗證", "domainPickerUnverified": "未驗證", "domainPickerInvalidSubdomainStructure": "此子域包含無效的字元或結構。當您保存時,它將被自動清除。", "domainPickerError": "錯誤", "domainPickerErrorLoadDomains": "載入組織域名失敗", "domainPickerErrorCheckAvailability": "檢查域可用性失敗", "domainPickerInvalidSubdomain": "無效的子域", "domainPickerInvalidSubdomainRemoved": "輸入 \"{sub}\" 已被移除,因為其無效。", "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 無法為 {domain} 變為有效。", "domainPickerSubdomainSanitized": "子域已淨化", "domainPickerSubdomainCorrected": "\"{sub}\" 已被更正為 \"{sanitized}\"", "orgAuthSignInTitle": "登錄到您的組織", "orgAuthChooseIdpDescription": "選擇您的身份提供商以繼續", "orgAuthNoIdpConfigured": "此機構沒有配置任何身份提供者。您可以使用您的 Pangolin 身份登錄。", "orgAuthSignInWithPangolin": "使用 Pangolin 登錄", "orgAuthSignInToOrg": "登入組織", "orgAuthSelectOrgTitle": "組織登入", "orgAuthSelectOrgDescription": "輸入您的組織 ID 以繼續", "orgAuthOrgIdPlaceholder": "your-organization", "orgAuthOrgIdHelp": "輸入您組織的唯一識別碼", "orgAuthSelectOrgHelp": "輸入組織 ID 後,您將被導向到組織的登入頁面,在那裡您可以使用 SSO 或組織憑證。", "orgAuthRememberOrgId": "記住此組織 ID", "orgAuthBackToSignIn": "返回標準登入", "orgAuthNoAccount": "沒有帳戶?", "subscriptionRequiredToUse": "需要訂閱才能使用此功能。", "idpDisabled": "身份提供者已禁用。", "orgAuthPageDisabled": "組織認證頁面已禁用。", "domainRestartedDescription": "域驗證重新啟動成功", "resourceAddEntrypointsEditFile": "編輯文件:config/traefik/traefik_config.yml", "resourceExposePortsEditFile": "編輯文件:docker-compose.yml", "emailVerificationRequired": "需要電子郵件驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", "twoFactorSetupRequired": "需要設置雙因素身份驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", "additionalSecurityRequired": "需要額外的安全", "organizationRequiresAdditionalSteps": "這個組織需要額外的安全步驟才能訪問資源。", "completeTheseSteps": "完成這些步驟", "enableTwoFactorAuthentication": "啟用兩步驗證", "completeSecuritySteps": "完成安全步驟", "securitySettings": "安全設定", "dangerSection": "危險區域", "dangerSectionDescription": "永久刪除與此組織相關的所有資料", "securitySettingsDescription": "配置您組織的安全策略", "requireTwoFactorForAllUsers": "所有用戶需要兩步驗證", "requireTwoFactorDescription": "如果啟用,此組織的所有內部用戶必須啟用雙重身份驗證才能訪問組織。", "requireTwoFactorDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", "requireTwoFactorCannotEnableDescription": "您必須為您的帳戶啟用雙重身份驗證才能對所有用戶", "maxSessionLength": "最大會話長度", "maxSessionLengthDescription": "設置用戶會話的最長時間。此後用戶需要重新驗證。", "maxSessionLengthDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", "selectSessionLength": "選擇會話長度", "unenforced": "未執行", "1Hour": "1 小時", "3Hours": "3 小時", "6Hours": "6 小時", "12Hours": "12 小時", "1DaySession": "1天", "3Days": "3 天", "7Days": "7 天", "14Days": "14 天", "30DaysSession": "30 天", "90DaysSession": "90 天", "180DaysSession": "180天", "passwordExpiryDays": "密碼過期", "editPasswordExpiryDescription": "設置用戶需要更改密碼之前的天數。", "selectPasswordExpiry": "選擇密碼過期", "30Days": "30 天", "1Day": "1天", "60Days": "60天", "90Days": "90 天", "180Days": "180天", "1Year": "1 年", "subscriptionBadge": "需要訂閱", "securityPolicyChangeWarning": "安全政策更改警告", "securityPolicyChangeDescription": "您即將更改安全政策設置。保存後,您可能需要重新認證以遵守這些政策更新。 所有不符合要求的用戶也需要重新認證。", "securityPolicyChangeConfirmMessage": "我確認", "securityPolicyChangeWarningText": "這將影響組織中的所有用戶", "authPageErrorUpdateMessage": "更新身份驗證頁面設置時出錯", "authPageErrorUpdate": "無法更新認證頁面", "authPageDomainUpdated": "驗證頁面網域更新成功", "healthCheckNotAvailable": "本地的", "rewritePath": "重寫路徑", "rewritePathDescription": "在轉發到目標之前,可以選擇重寫路徑。", "continueToApplication": "繼續應用", "checkingInvite": "正在檢查邀請", "setResourceHeaderAuth": "設置 ResourceHeaderAuth", "resourceHeaderAuthRemove": "移除 Header 身份驗證", "resourceHeaderAuthRemoveDescription": "已成功刪除 Header 身份驗證。", "resourceErrorHeaderAuthRemove": "刪除 Header 身份驗證失敗", "resourceErrorHeaderAuthRemoveDescription": "無法刪除資源的 Header 身份驗證。", "resourceHeaderAuthProtectionEnabled": "Header 認證已啟用", "resourceHeaderAuthProtectionDisabled": "Header 身份驗證已禁用", "headerAuthRemove": "刪除 Header 認證", "headerAuthAdd": "添加頁首認證", "resourceErrorHeaderAuthSetup": "設置頁首認證失敗", "resourceErrorHeaderAuthSetupDescription": "無法設置資源的 Header 身份驗證。", "resourceHeaderAuthSetup": "Header 認證設置成功", "resourceHeaderAuthSetupDescription": "Header 認證已成功設置。", "resourceHeaderAuthSetupTitle": "設置 Header 身份驗證", "resourceHeaderAuthSetupTitleDescription": "使用 HTTP 頭身份驗證來設置基本身份驗證資訊(使用者名稱和密碼)。使用 https://username:password@resource.example.com 訪問它", "resourceHeaderAuthSubmit": "設置 Header 身份驗證", "actionSetResourceHeaderAuth": "設置 Header 身份驗證", "enterpriseEdition": "企業版", "unlicensed": "未授權", "beta": "測試版", "manageUserDevices": "使用者裝置", "manageUserDevicesDescription": "查看和管理使用者用於私密連接資源的裝置", "downloadClientBannerTitle": "下載 Pangolin 客戶端", "downloadClientBannerDescription": "下載適用於您系統的 Pangolin 客戶端,以連接到 Pangolin 網路並私密存取資源。", "manageMachineClients": "管理機器客戶端", "manageMachineClientsDescription": "建立和管理伺服器和系統用於私密連接資源的客戶端", "machineClientsBannerTitle": "伺服器與自動化系統", "machineClientsBannerDescription": "機器客戶端適用於與特定使用者無關的伺服器和自動化系統。它們使用 ID 和密鑰進行驗證,可以透過 Pangolin CLI、Olm CLI 或 Olm 容器執行。", "machineClientsBannerPangolinCLI": "Pangolin CLI", "machineClientsBannerOlmCLI": "Olm CLI", "machineClientsBannerOlmContainer": "Olm 容器", "clientsTableUserClients": "使用者", "clientsTableMachineClients": "機器", "licenseTableValidUntil": "有效期至", "saasLicenseKeysSettingsTitle": "企業許可證", "saasLicenseKeysSettingsDescription": "為自我託管的 Pangolin 實例生成和管理企業許可證金鑰", "sidebarEnterpriseLicenses": "許可協議", "generateLicenseKey": "生成許可證金鑰", "generateLicenseKeyForm": { "validation": { "emailRequired": "請輸入一個有效的電子郵件地址", "useCaseTypeRequired": "請選擇一個使用的案例類型", "firstNameRequired": "必填名", "lastNameRequired": "姓氏是必填項", "primaryUseRequired": "請描述您的主要使用", "jobTitleRequiredBusiness": "企業使用必須有職位頭銜。", "industryRequiredBusiness": "商業使用需要工業", "stateProvinceRegionRequired": "州/省/地區是必填項", "postalZipCodeRequired": "郵政編碼是必需的", "companyNameRequiredBusiness": "企業使用需要公司名稱", "countryOfResidenceRequiredBusiness": "商業使用必須是居住國", "countryRequiredPersonal": "國家需要個人使用", "agreeToTermsRequired": "您必須同意條款", "complianceConfirmationRequired": "您必須確認遵守 Fossorial Commercial License" }, "useCaseOptions": { "personal": { "title": "個人使用", "description": "個人非商業用途,如學習、個人項目或實驗。" }, "business": { "title": "商業使用", "description": "供組織、公司或商業或創收活動使用。" } }, "steps": { "emailLicenseType": { "title": "電子郵件和許可證類型", "description": "輸入您的電子郵件並選擇您的許可證類型" }, "personalInformation": { "title": "個人資訊", "description": "告訴我們自己的資訊" }, "contactInformation": { "title": "聯繫資訊", "description": "您的聯繫資訊" }, "termsGenerate": { "title": "條款並生成", "description": "審閱並接受條款生成您的許可證" } }, "alerts": { "commercialUseDisclosure": { "title": "使用情況披露", "description": "選擇能準確反映您預定用途的許可等級。 個人許可證允許對個人、非商業性或小型商業活動免費使用軟體,年收入毛額不到 100,000 美元。 超出這些限度的任何用途,包括在企業、組織內的用途。 或其他創收環境——需要有效的企業許可證和支付適用的許可證費用。 所有用戶,不論是個人還是企業,都必須遵守寄養商業許可證條款。" }, "trialPeriodInformation": { "title": "試用期資訊", "description": "此許可證金鑰使企業特性能夠持續 7 天的評價。 在評估期過後繼續訪問付費功能需要在有效的個人或企業許可證下啟用。對於企業許可證,請聯絡 Sales@pangolin.net。" } }, "form": { "useCaseQuestion": "您是否正在使用 Pangolin 進行個人或商業使用?", "firstName": "名字", "lastName": "名字", "jobTitle": "工作頭銜:", "primaryUseQuestion": "您主要計劃使用 Pangolin 嗎?", "industryQuestion": "您的行業是什麼?", "prospectiveUsersQuestion": "您期望有多少預期用戶?", "prospectiveSitesQuestion": "您期望有多少站點(隧道)?", "companyName": "公司名稱", "countryOfResidence": "居住國", "stateProvinceRegion": "州/省/地區", "postalZipCode": "郵政編碼", "companyWebsite": "公司網站", "companyPhoneNumber": "公司電話號碼", "country": "國家", "phoneNumberOptional": "電話號碼 (可選)", "complianceConfirmation": "我確認我提供的資料是準確的,我遵守了寄養商業許可證。 報告不準確的資訊或錯誤的產品使用是違反許可證的行為,可能導致您的金鑰被撤銷。" }, "buttons": { "close": "關閉", "previous": "上一個", "next": "下一個", "generateLicenseKey": "生成許可證金鑰" }, "toasts": { "success": { "title": "許可證金鑰生成成功", "description": "您的許可證金鑰已經生成並準備使用。" }, "error": { "title": "生成許可證金鑰失敗", "description": "生成許可證金鑰時出錯。" } } }, "priority": "優先權", "priorityDescription": "先評估更高優先度線路。優先度 = 100 意味著自動排序(系統決定). 使用另一個數字強制執行手動優先度。", "instanceName": "實例名稱", "pathMatchModalTitle": "配置路徑匹配", "pathMatchModalDescription": "根據傳入請求的路徑設置匹配方式。", "pathMatchType": "匹配類型", "pathMatchPrefix": "前綴", "pathMatchExact": "精準的", "pathMatchRegex": "正則表達式", "pathMatchValue": "路徑值", "clear": "清空", "saveChanges": "保存更改", "pathMatchRegexPlaceholder": "^/api/.*", "pathMatchDefaultPlaceholder": "/路徑", "pathMatchPrefixHelp": "範例: /api 匹配/api, /api/users 等。", "pathMatchExactHelp": "範例:/api 匹配僅限/api", "pathMatchRegexHelp": "例如:^/api/.* 匹配/api/why", "pathRewriteModalTitle": "配置路徑重寫", "pathRewriteModalDescription": "在轉發到目標之前變換匹配的路徑。", "pathRewriteType": "重寫類型", "pathRewritePrefixOption": "前綴 - 替換前綴", "pathRewriteExactOption": "精確-替換整個路徑", "pathRewriteRegexOption": "正則表達式 - 替換模式", "pathRewriteStripPrefixOption": "刪除前綴 - 刪除前綴", "pathRewriteValue": "重寫值", "pathRewriteRegexPlaceholder": "/new/$1", "pathRewriteDefaultPlaceholder": "/new-path", "pathRewritePrefixHelp": "用此值替換匹配的前綴", "pathRewriteExactHelp": "當路徑匹配時用此值替換整個路徑", "pathRewriteRegexHelp": "使用抓取組,如$1,$2來替換", "pathRewriteStripPrefixHelp": "留空以脫離前綴或提供新的前綴", "pathRewritePrefix": "前綴", "pathRewriteExact": "精準的", "pathRewriteRegex": "正則表達式", "pathRewriteStrip": "帶狀圖", "pathRewriteStripLabel": "條形圖", "sidebarEnableEnterpriseLicense": "啟用企業許可證", "cannotbeUndone": "無法撤消。", "toConfirm": "確認", "deleteClientQuestion": "您確定要從站點和組織中刪除客戶嗎?", "clientMessageRemove": "一旦刪除,用戶端將無法連接到站點。", "sidebarLogs": "日誌", "request": "請求", "requests": "請求", "logs": "日誌", "logsSettingsDescription": "監視從此 orginization 中收集的日誌", "searchLogs": "搜索日誌...", "action": "行動", "actor": "執行者", "timestamp": "時間戳", "accessLogs": "訪問日誌", "exportCsv": "導出 CSV", "exportError": "匯出 CSV 時發生未知錯誤", "exportCsvTooltip": "在時間範圍內", "actorId": "執行者 ID", "allowedByRule": "根據規則允許", "allowedNoAuth": "無認證", "validAccessToken": "有效訪問令牌", "validHeaderAuth": "有效的 Header 身份驗證", "validPincode": "有效的 Pincode", "validPassword": "有效密碼", "validEmail": "有效的 email", "validSSO": "有效的 SSO", "resourceBlocked": "資源被阻止", "droppedByRule": "被規則刪除", "noSessions": "無會話", "temporaryRequestToken": "臨時請求令牌", "noMoreAuthMethods": "無有效授權", "ip": "IP", "reason": "原因", "requestLogs": "請求日誌", "requestAnalytics": "請求分析", "host": "主機", "location": "地點", "actionLogs": "操作日誌", "sidebarLogsRequest": "請求日誌", "sidebarLogsAccess": "訪問日誌", "sidebarLogsAction": "操作日誌", "logRetention": "日誌保留", "logRetentionDescription": "管理不同類型的日誌為這個機構保留多長時間或禁用這些日誌", "requestLogsDescription": "查看此機構資源的詳細請求日誌", "requestAnalyticsDescription": "查看此組織資源的詳細請求分析", "logRetentionRequestLabel": "請求日誌保留", "logRetentionRequestDescription": "保留請求日誌的時間", "logRetentionAccessLabel": "訪問日誌保留", "logRetentionAccessDescription": "保留訪問日誌的時間", "logRetentionActionLabel": "動作日誌保留", "logRetentionActionDescription": "保留操作日誌的時間", "logRetentionDisabled": "已禁用", "logRetention3Days": "3 天", "logRetention7Days": "7 天", "logRetention14Days": "14 天", "logRetention30Days": "30 天", "logRetention90Days": "90 天", "logRetentionForever": "永遠的", "logRetentionEndOfFollowingYear": "次年年底", "actionLogsDescription": "查看此機構執行的操作歷史", "accessLogsDescription": "查看此機構資源的訪問認證請求", "licenseRequiredToUse": "需要企業許可證才能使用此功能。", "certResolver": "證書解決器", "certResolverDescription": "選擇用於此資源的證書解析器。", "selectCertResolver": "選擇證書解析", "enterCustomResolver": "輸入自訂解析器", "preferWildcardCert": "喜歡通配符證書", "unverified": "未驗證", "domainSetting": "域設置", "domainSettingDescription": "配置您的域的設置", "preferWildcardCertDescription": "嘗試生成通配符證書(需要正確配置的證書解析器)。", "recordName": "記錄名稱", "auto": "自動操作", "TTL": "TTL", "howToAddRecords": "如何添加記錄", "dnsRecord": "DNS 記錄", "required": "必填", "domainSettingsUpdated": "域設置更新成功", "orgOrDomainIdMissing": "缺少機構或域 ID", "loadingDNSRecords": "正在載入 DNS 記錄...", "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。請更新到最新版本以獲取最佳體驗。", "client": "用戶端:", "proxyProtocol": "代理協議設置", "proxyProtocolDescription": "配置代理協議以保留 TCP/UDP 服務的用戶端 IP 位址。", "enableProxyProtocol": "啟用代理協議", "proxyProtocolInfo": "為 TCP/UDP 後端保留用戶端 IP 位址", "proxyProtocolVersion": "代理協議版本", "version1": " 版本 1 (推薦)", "version2": "版本 2", "versionDescription": "版本 1 是基於文本和廣泛支持的版本。版本 2 是二進制和更有效率但不那麼相容。", "warning": "警告", "proxyProtocolWarning": "您的後端應用程式必須配置為接受代理協議連接。如果您的後端不支持代理協議,啟用這將會中斷所有連接。 請務必從 Traefik 配置您的後端到信任代理協議標題。", "restarting": "正在重啟...", "manual": "手動模式", "messageSupport": "消息支持", "supportNotAvailableTitle": "支持不可用", "supportNotAvailableDescription": "支持現在不可用。您可以發送電子郵件到 support@pangolin.net。", "supportRequestSentTitle": "支持請求已發送", "supportRequestSentDescription": "您的消息已成功發送。", "supportRequestFailedTitle": "發送請求失敗", "supportRequestFailedDescription": "發送您的支持請求時出錯。", "supportSubjectRequired": "主題是必填項", "supportSubjectMaxLength": "主題必須是 255 個或更少的字元", "supportMessageRequired": "消息是必填項", "supportReplyTo": "回復給", "supportSubject": "議題", "supportSubjectPlaceholder": "輸入主題", "supportMessage": "留言", "supportMessagePlaceholder": "輸入您的消息", "supportSending": "正在發送...", "supportSend": "發送", "supportMessageSent": "消息已發送!", "supportWillContact": "我們很快就會聯繫起來!", "selectLogRetention": "選擇保留日誌", "terms": "條款", "privacy": "隱私權", "security": "安全性", "docs": "文件", "deviceActivation": "裝置啟用", "deviceCodeInvalidFormat": "代碼必須為 9 個字元(例如:A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "代碼無效或已過期", "deviceCodeVerifyFailed": "驗證裝置代碼失敗", "signedInAs": "已登入為", "deviceCodeEnterPrompt": "輸入裝置上顯示的代碼", "continue": "繼續", "deviceUnknownLocation": "未知位置", "deviceAuthorizationRequested": "此授權請求來自 {location},時間為 {date}。請確保您信任此裝置,因為它將獲得帳戶存取權限。", "deviceLabel": "裝置:{deviceName}", "deviceWantsAccess": "想要存取您的帳戶", "deviceExistingAccess": "現有存取權限:", "deviceFullAccess": "完整帳戶存取權限", "deviceOrganizationsAccess": "存取您帳戶有權限的所有組織", "deviceAuthorize": "授權 {applicationName}", "deviceConnected": "裝置已連接!", "deviceAuthorizedMessage": "裝置已獲授權存取您的帳戶。請返回客戶端應用程式。", "pangolinCloud": "Pangolin 雲端", "viewDevices": "查看裝置", "viewDevicesDescription": "管理您已連接的裝置", "noDevices": "找不到裝置", "dateCreated": "建立日期", "unnamedDevice": "未命名裝置", "deviceQuestionRemove": "您確定要刪除此裝置嗎?", "deviceMessageRemove": "此操作無法復原。", "deviceDeleteConfirm": "刪除裝置", "deleteDevice": "刪除裝置", "errorLoadingDevices": "載入裝置時發生錯誤", "failedToLoadDevices": "載入裝置失敗", "deviceDeleted": "裝置已刪除", "deviceDeletedDescription": "裝置已成功刪除。", "errorDeletingDevice": "刪除裝置時發生錯誤", "failedToDeleteDevice": "刪除裝置失敗", "showColumns": "顯示列", "hideColumns": "隱藏列", "columnVisibility": "列可見性", "toggleColumn": "切換 {columnName} 列", "allColumns": "全部列", "defaultColumns": "默認列", "customizeView": "自訂視圖", "viewOptions": "查看選項", "selectAll": "選擇所有", "selectNone": "沒有選擇", "selectedResources": "選定的資源", "enableSelected": "啟用選中的", "disableSelected": "禁用選中的", "checkSelectedStatus": "檢查選中的狀態", "clients": "客戶端", "accessClientSelect": "選擇機器客戶端", "resourceClientDescription": "可以存取此資源的機器客戶端", "regenerate": "重新產生", "credentials": "憑證", "savecredentials": "儲存憑證", "regenerateCredentialsButton": "重新產生憑證", "regenerateCredentials": "重新產生憑證", "generatedcredentials": "已產生的憑證", "copyandsavethesecredentials": "複製並儲存這些憑證", "copyandsavethesecredentialsdescription": "離開此頁面後將不會再顯示這些憑證。請立即安全儲存。", "credentialsSaved": "憑證已儲存", "credentialsSavedDescription": "憑證已成功重新產生並儲存。", "credentialsSaveError": "憑證儲存錯誤", "credentialsSaveErrorDescription": "重新產生和儲存憑證時發生錯誤。", "regenerateCredentialsWarning": "重新產生憑證將使先前的憑證失效並導致斷線。請確保更新任何使用這些憑證的設定。", "confirm": "確認", "regenerateCredentialsConfirmation": "您確定要重新產生憑證嗎?", "endpoint": "端點", "Id": "ID", "SecretKey": "密鑰", "niceId": "友善 ID", "niceIdUpdated": "友善 ID 已更新", "niceIdUpdatedSuccessfully": "友善 ID 更新成功", "niceIdUpdateError": "更新友善 ID 時發生錯誤", "niceIdUpdateErrorDescription": "更新友善 ID 時發生錯誤。", "niceIdCannotBeEmpty": "友善 ID 不能為空", "enterIdentifier": "輸入識別碼", "identifier": "識別碼", "deviceLoginUseDifferentAccount": "不是您嗎?使用其他帳戶。", "deviceLoginDeviceRequestingAccessToAccount": "有裝置正在請求存取此帳戶。", "noData": "無資料", "machineClients": "機器客戶端", "install": "安裝", "run": "執行", "clientNameDescription": "客戶端的顯示名稱,可以稍後更改。", "clientAddress": "客戶端位址(進階)", "setupFailedToFetchSubnet": "取得預設子網路失敗", "setupSubnetAdvanced": "子網路(進階)", "setupSubnetDescription": "此組織內部網路的子網路。", "setupUtilitySubnet": "工具子網路(進階)", "setupUtilitySubnetDescription": "此組織別名位址和 DNS 伺服器的子網路。", "siteRegenerateAndDisconnect": "重新產生並斷開連接", "siteRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此站點的連接嗎?", "siteRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開站點連接。站點需要使用新憑證重新啟動。", "siteRegenerateCredentialsConfirmation": "您確定要重新產生此站點的憑證嗎?", "siteRegenerateCredentialsWarning": "這將重新產生憑證。站點將保持連接,直到您手動重新啟動並使用新憑證。", "clientRegenerateAndDisconnect": "重新產生並斷開連接", "clientRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此客戶端的連接嗎?", "clientRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開客戶端連接。客戶端需要使用新憑證重新啟動。", "clientRegenerateCredentialsConfirmation": "您確定要重新產生此客戶端的憑證嗎?", "clientRegenerateCredentialsWarning": "這將重新產生憑證。客戶端將保持連接,直到您手動重新啟動並使用新憑證。", "remoteExitNodeRegenerateAndDisconnect": "重新產生並斷開連接", "remoteExitNodeRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此遠端出口節點的連接嗎?", "remoteExitNodeRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開遠端出口節點連接。遠端出口節點需要使用新憑證重新啟動。", "remoteExitNodeRegenerateCredentialsConfirmation": "您確定要重新產生此遠端出口節點的憑證嗎?", "remoteExitNodeRegenerateCredentialsWarning": "這將重新產生憑證。遠端出口節點將保持連接,直到您手動重新啟動並使用新憑證。", "agent": "代理", "personalUseOnly": "僅限個人使用", "loginPageLicenseWatermark": "此實例僅授權個人使用。", "instanceIsUnlicensed": "此實例未授權。", "portRestrictions": "連接埠限制", "allPorts": "全部", "custom": "自訂", "allPortsAllowed": "允許所有連接埠", "allPortsBlocked": "阻擋所有連接埠", "tcpPortsDescription": "指定此資源允許的 TCP 連接埠。使用「*」表示所有連接埠,留空表示阻擋全部,或輸入以逗號分隔的連接埠和範圍(例如:80,443,8000-9000)。", "udpPortsDescription": "指定此資源允許的 UDP 連接埠。使用「*」表示所有連接埠,留空表示阻擋全部,或輸入以逗號分隔的連接埠和範圍(例如:53,123,500-600)。", "organizationLoginPageTitle": "組織登入頁面", "organizationLoginPageDescription": "自訂此組織的登入頁面", "resourceLoginPageTitle": "資源登入頁面", "resourceLoginPageDescription": "自訂個別資源的登入頁面", "enterConfirmation": "輸入確認", "blueprintViewDetails": "詳細資訊", "defaultIdentityProvider": "預設身份提供者", "defaultIdentityProviderDescription": "當選擇預設身份提供者時,使用者將自動被重新導向到該提供者進行驗證。", "editInternalResourceDialogNetworkSettings": "網路設定", "editInternalResourceDialogAccessPolicy": "存取策略", "editInternalResourceDialogAddRoles": "新增角色", "editInternalResourceDialogAddUsers": "新增使用者", "editInternalResourceDialogAddClients": "新增客戶端", "editInternalResourceDialogDestinationLabel": "目的地", "editInternalResourceDialogDestinationDescription": "指定內部資源的目的地位址。根據所選模式,這可以是主機名稱、IP 位址或 CIDR 範圍。可選擇設定內部 DNS 別名以便識別。", "editInternalResourceDialogPortRestrictionsDescription": "限制對特定 TCP/UDP 連接埠的存取,或允許/阻擋所有連接埠。", "editInternalResourceDialogTcp": "TCP", "editInternalResourceDialogUdp": "UDP", "editInternalResourceDialogIcmp": "ICMP", "editInternalResourceDialogAccessControl": "存取控制", "editInternalResourceDialogAccessControlDescription": "控制哪些角色、使用者和機器客戶端在連接時可以存取此資源。管理員始終擁有存取權限。", "editInternalResourceDialogPortRangeValidationError": "連接埠範圍必須是「*」表示所有連接埠,或以逗號分隔的連接埠和範圍列表(例如:「80,443,8000-9000」)。連接埠必須介於 1 到 65535 之間。", "orgAuthWhatsThis": "我在哪裡可以找到我的組織 ID?", "learnMore": "了解更多", "backToHome": "返回首頁", "needToSignInToOrg": "需要使用您組織的身份提供者嗎?", "maintenanceMode": "維護模式", "maintenanceModeDescription": "向訪客顯示維護頁面", "maintenanceModeType": "維護模式類型", "showMaintenancePage": "向訪客顯示維護頁面", "enableMaintenanceMode": "啟用維護模式", "automatic": "自動", "automaticModeDescription": "僅在所有後端目標都關閉或不健康時顯示維護頁面。只要至少有一個目標健康,您的資源就會正常運作。", "forced": "強制", "forcedModeDescription": "無論後端健康狀況如何,始終顯示維護頁面。當您想要阻止所有存取時,用於計劃維護。", "warning:": "警告:", "forcedeModeWarning": "所有流量將被導向維護頁面。您的後端資源將不會收到任何請求。", "pageTitle": "頁面標題", "pageTitleDescription": "維護頁面上顯示的主標題", "maintenancePageMessage": "維護訊息", "maintenancePageMessagePlaceholder": "我們很快就會回來!我們的網站目前正在進行預定維護。", "maintenancePageMessageDescription": "說明維護的詳細訊息", "maintenancePageTimeTitle": "預計完成時間(可選)", "maintenanceTime": "例如:2 小時、11 月 1 日下午 5:00", "maintenanceEstimatedTimeDescription": "您預計何時完成維護", "editDomain": "編輯網域", "editDomainDescription": "為您的資源選擇網域", "maintenanceModeDisabledTooltip": "此功能需要有效的授權才能啟用。", "maintenanceScreenTitle": "服務暫時無法使用", "maintenanceScreenMessage": "我們目前遇到技術問題。請稍後再試。", "maintenanceScreenEstimatedCompletion": "預計完成時間:", "createInternalResourceDialogDestinationRequired": "目的地為必填欄位" } ================================================ FILE: next.config.ts ================================================ import type { NextConfig } from "next"; import createNextIntlPlugin from "next-intl/plugin"; const withNextIntl = createNextIntlPlugin(); const nextConfig: NextConfig = { reactStrictMode: false, eslint: { ignoreDuringBuilds: true }, experimental: { reactCompiler: true }, output: "standalone" }; export default withNextIntl(nextConfig); ================================================ FILE: package.json ================================================ { "name": "@fosrl/pangolin", "version": "0.0.0", "private": true, "type": "module", "description": "Identity-aware VPN and proxy for remote access to anything, anywhere and Dashboard UI", "homepage": "https://github.com/fosrl/pangolin", "repository": { "type": "git", "url": "https://github.com/fosrl/pangolin" }, "license": "SEE LICENSE IN LICENSE AND README.md", "scripts": { "dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts", "dev:check": "npx tsc --noEmit && npm run format:check", "dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push", "db:generate": "drizzle-kit generate --config=./drizzle.config.ts", "db:push": "npx tsx server/db/migrate.ts", "db:studio": "drizzle-kit studio --config=./drizzle.config.ts", "db:clear-migrations": "rm -rf server/migrations", "set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json", "set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json", "set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json", "set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts && cp drizzle.sqlite.config.ts drizzle.config.ts && cp server/setup/migrationsSqlite.ts server/setup/migrations.ts", "set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts && cp drizzle.pg.config.ts drizzle.config.ts && cp server/setup/migrationsPg.ts server/setup/migrations.ts", "build:next": "next build", "build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs", "start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs", "email": "email dev --dir server/emails/templates --port 3005", "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs", "format:check": "prettier --check .", "format": "prettier --write ." }, "dependencies": { "@asteasolutions/zod-to-openapi": "8.4.1", "@aws-sdk/client-s3": "3.1011.0", "@faker-js/faker": "10.3.0", "@headlessui/react": "2.2.9", "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "4.7.0", "@node-rs/argon2": "2.0.2", "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@radix-ui/react-avatar": "1.1.11", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-icons": "1.3.2", "@radix-ui/react-label": "2.1.8", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-progress": "1.1.8", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.8", "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-tooltip": "1.2.8", "@react-email/components": "1.0.8", "@react-email/render": "2.0.4", "@react-email/tailwind": "2.0.5", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.0", "@tailwindcss/forms": "0.5.11", "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", "arctic": "3.7.0", "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.6", "crypto-js": "4.2.0", "d3": "7.9.0", "drizzle-orm": "0.45.1", "express": "5.2.1", "express-rate-limit": "8.3.0", "glob": "13.0.6", "helmet": "8.1.0", "http-errors": "2.0.1", "input-otp": "1.4.2", "ioredis": "5.10.0", "jmespath": "0.16.0", "js-yaml": "4.1.1", "jsonwebtoken": "9.0.3", "lucide-react": "0.577.0", "maxmind": "5.0.5", "moment": "2.30.1", "next": "15.5.12", "next-intl": "4.8.3", "next-themes": "0.4.6", "nextjs-toploader": "3.9.17", "node-cache": "5.1.2", "nodemailer": "8.0.1", "oslo": "1.2.1", "pg": "8.20.0", "posthog-node": "5.28.0", "qrcode.react": "4.2.0", "react": "19.2.4", "react-day-picker": "9.14.0", "react-dom": "19.2.4", "react-easy-sort": "1.8.0", "react-hook-form": "7.71.2", "react-icons": "5.6.0", "recharts": "2.15.4", "reodotdev": "1.1.0", "resend": "6.9.2", "semver": "7.7.4", "sshpk": "^1.18.0", "stripe": "20.4.1", "swagger-ui-express": "5.0.1", "tailwind-merge": "3.5.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", "winston": "3.19.0", "winston-daily-rotate-file": "5.0.0", "ws": "8.19.0", "yaml": "2.8.2", "yargs": "18.0.0", "zod": "4.3.6", "zod-validation-error": "5.0.0" }, "devDependencies": { "@dotenvx/dotenvx": "1.54.1", "@esbuild-plugins/tsconfig-paths": "0.1.2", "@react-email/preview-server": "5.2.10", "@tailwindcss/postcss": "4.2.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", "@types/crypto-js": "4.2.2", "@types/d3": "7.4.3", "@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": "25.3.5", "@types/nodemailer": "7.0.11", "@types/nprogress": "0.2.3", "@types/pg": "8.18.0", "@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", "babel-plugin-react-compiler": "1.0.0", "drizzle-kit": "0.31.10", "esbuild": "0.27.3", "esbuild-node-externals": "1.20.1", "eslint": "10.0.3", "eslint-config-next": "16.1.7", "postcss": "8.5.8", "prettier": "3.8.1", "react-email": "5.2.10", "tailwindcss": "4.2.1", "tsc-alias": "1.8.16", "tsx": "4.21.0", "typescript": "5.9.3", "typescript-eslint": "8.56.1" }, "overrides": { "esbuild": "0.27.3", "dompurify": "3.3.2" } } ================================================ FILE: postcss.config.mjs ================================================ /** @type {import('postcss-load-config').Config} */ const config = { plugins: { "@tailwindcss/postcss": {} } }; export default config; ================================================ FILE: server/apiServer.ts ================================================ import express from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import config from "@server/lib/config"; import logger from "@server/logger"; import { errorHandlerMiddleware, notFoundMiddleware } from "@server/middlewares"; import { authenticated, unauthenticated } from "#dynamic/routers/external"; import { router as wsRouter, handleWSUpgrade } from "#dynamic/routers/ws"; import { logIncomingMiddleware } from "./middlewares/logIncoming"; import { csrfProtectionMiddleware } from "./middlewares/csrfProtection"; import helmet from "helmet"; import { build } from "./build"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; import HttpCode from "./types/HttpCode"; import requestTimeoutMiddleware from "./middlewares/requestTimeout"; import { createStore } from "#dynamic/lib/rateLimitStore"; import { stripDuplicateSesions } from "./middlewares/stripDuplicateSessions"; import { corsWithLoginPageSupport } from "@server/lib/corsWithLoginPage"; import { hybridRouter } from "#dynamic/routers/hybrid"; import { billingWebhookHandler } from "#dynamic/routers/billing/webhooks"; const dev = config.isDev; const externalPort = config.getRawConfig().server.external_port; export function createApiServer() { const apiServer = express(); const prefix = `/api/v1`; const trustProxy = config.getRawConfig().server.trust_proxy; if (trustProxy) { apiServer.set("trust proxy", trustProxy); } if (build == "saas") { apiServer.post( `${prefix}/billing/webhooks`, express.raw({ type: "application/json" }), billingWebhookHandler ); } const corsConfig = config.getRawConfig().server.cors; const options = { ...(corsConfig?.origins ? { origin: corsConfig.origins } : { origin: (origin: any, callback: any) => { callback(null, true); } }), ...(corsConfig?.methods && { methods: corsConfig.methods }), ...(corsConfig?.allowed_headers && { allowedHeaders: corsConfig.allowed_headers }), credentials: !(corsConfig?.credentials === false) }; if (build == "oss" || !corsConfig) { logger.debug("Using CORS options", options); apiServer.use(cors(options)); } else if (corsConfig) { // Use the custom CORS middleware with loginPage support apiServer.use(corsWithLoginPageSupport(corsConfig)); } if (!dev) { apiServer.use(helmet()); apiServer.use(csrfProtectionMiddleware); } apiServer.use(stripDuplicateSesions); apiServer.use(cookieParser()); apiServer.use(express.json()); // Add request timeout middleware apiServer.use(requestTimeoutMiddleware(60000)); // 60 second timeout apiServer.use(logIncomingMiddleware); if (build !== "oss") { apiServer.use(`${prefix}/hybrid`, hybridRouter); // put before rate limiting because we will rate limit there separately because some of the routes are heavily used } if (!dev) { apiServer.use( rateLimit({ windowMs: config.getRawConfig().rate_limits.global.window_minutes * 60 * 1000, max: config.getRawConfig().rate_limits.global.max_requests, keyGenerator: (req) => `apiServerGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`, handler: (req, res, next) => { const message = `Rate limit exceeded. You can make ${config.getRawConfig().rate_limits.global.max_requests} requests every ${config.getRawConfig().rate_limits.global.window_minutes} minute(s).`; return next( createHttpError(HttpCode.TOO_MANY_REQUESTS, message) ); }, store: createStore() }) ); } // API routes apiServer.use(prefix, unauthenticated); apiServer.use(prefix, authenticated); // WebSocket routes apiServer.use(prefix, wsRouter); // Error handling apiServer.use(notFoundMiddleware); apiServer.use(errorHandlerMiddleware); // Create HTTP server const httpServer = apiServer.listen(externalPort, (err?: any) => { if (err) throw err; logger.info( `API server is running on http://localhost:${externalPort}` ); }); // Handle WebSocket upgrades handleWSUpgrade(httpServer); return httpServer; } ================================================ FILE: server/auth/actions.ts ================================================ import { Request } from "express"; import { db } from "@server/db"; import { userActions, roleActions, userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export enum ActionsEnum { createOrgUser = "createOrgUser", listOrgs = "listOrgs", listUserOrgs = "listUserOrgs", createOrg = "createOrg", // deleteOrg = "deleteOrg", getOrg = "getOrg", updateOrg = "updateOrg", deleteOrg = "deleteOrg", createSite = "createSite", deleteSite = "deleteSite", getSite = "getSite", listSites = "listSites", updateSite = "updateSite", resetSiteBandwidth = "resetSiteBandwidth", reGenerateSecret = "reGenerateSecret", createResource = "createResource", deleteResource = "deleteResource", getResource = "getResource", listResources = "listResources", updateResource = "updateResource", createTarget = "createTarget", deleteTarget = "deleteTarget", getTarget = "getTarget", listTargets = "listTargets", updateTarget = "updateTarget", createRole = "createRole", deleteRole = "deleteRole", getRole = "getRole", listRoles = "listRoles", updateRole = "updateRole", inviteUser = "inviteUser", listInvitations = "listInvitations", removeInvitation = "removeInvitation", removeUser = "removeUser", listUsers = "listUsers", listSiteRoles = "listSiteRoles", listResourceRoles = "listResourceRoles", setResourceUsers = "setResourceUsers", setResourceRoles = "setResourceRoles", listResourceUsers = "listResourceUsers", // removeRoleSite = "removeRoleSite", // addRoleAction = "addRoleAction", // removeRoleAction = "removeRoleAction", // listRoleSites = "listRoleSites", listRoleResources = "listRoleResources", // listRoleActions = "listRoleActions", addUserRole = "addUserRole", // addUserSite = "addUserSite", // addUserAction = "addUserAction", // removeUserAction = "removeUserAction", // removeUserSite = "removeUserSite", getOrgUser = "getOrgUser", updateUser = "updateUser", getUser = "getUser", setResourcePassword = "setResourcePassword", setResourcePincode = "setResourcePincode", setResourceHeaderAuth = "setResourceHeaderAuth", setResourceWhitelist = "setResourceWhitelist", getResourceWhitelist = "getResourceWhitelist", generateAccessToken = "generateAccessToken", deleteAcessToken = "deleteAcessToken", listAccessTokens = "listAccessTokens", createResourceRule = "createResourceRule", deleteResourceRule = "deleteResourceRule", listResourceRules = "listResourceRules", updateResourceRule = "updateResourceRule", createSiteResource = "createSiteResource", deleteSiteResource = "deleteSiteResource", getSiteResource = "getSiteResource", listSiteResources = "listSiteResources", updateSiteResource = "updateSiteResource", createClient = "createClient", deleteClient = "deleteClient", archiveClient = "archiveClient", unarchiveClient = "unarchiveClient", blockClient = "blockClient", unblockClient = "unblockClient", updateClient = "updateClient", listClients = "listClients", getClient = "getClient", listOrgDomains = "listOrgDomains", getDomain = "getDomain", updateOrgDomain = "updateOrgDomain", getDNSRecords = "getDNSRecords", createNewt = "createNewt", createOlm = "createOlm", createIdp = "createIdp", updateIdp = "updateIdp", deleteIdp = "deleteIdp", listIdps = "listIdps", getIdp = "getIdp", createIdpOrg = "createIdpOrg", deleteIdpOrg = "deleteIdpOrg", listIdpOrgs = "listIdpOrgs", updateIdpOrg = "updateIdpOrg", checkOrgId = "checkOrgId", createApiKey = "createApiKey", deleteApiKey = "deleteApiKey", setApiKeyActions = "setApiKeyActions", setApiKeyOrgs = "setApiKeyOrgs", listApiKeyActions = "listApiKeyActions", listApiKeys = "listApiKeys", getApiKey = "getApiKey", getCertificate = "getCertificate", restartCertificate = "restartCertificate", billing = "billing", createOrgDomain = "createOrgDomain", deleteOrgDomain = "deleteOrgDomain", restartOrgDomain = "restartOrgDomain", sendUsageNotification = "sendUsageNotification", createRemoteExitNode = "createRemoteExitNode", updateRemoteExitNode = "updateRemoteExitNode", getRemoteExitNode = "getRemoteExitNode", listRemoteExitNode = "listRemoteExitNode", deleteRemoteExitNode = "deleteRemoteExitNode", updateOrgUser = "updateOrgUser", createLoginPage = "createLoginPage", updateLoginPage = "updateLoginPage", getLoginPage = "getLoginPage", deleteLoginPage = "deleteLoginPage", listBlueprints = "listBlueprints", getBlueprint = "getBlueprint", applyBlueprint = "applyBlueprint", viewLogs = "viewLogs", exportLogs = "exportLogs", listApprovals = "listApprovals", updateApprovals = "updateApprovals", signSshKey = "signSshKey" } export async function checkUserActionPermission( actionId: string, req: Request ): Promise { const userId = req.user?.userId; if (!userId) { throw createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated"); } if (!req.userOrgId) { throw createHttpError( HttpCode.BAD_REQUEST, "Organization ID is required" ); } try { let userOrgRoleId = req.userOrgRoleId; // If userOrgRoleId is not available on the request, fetch it if (userOrgRoleId === undefined) { const userOrgRole = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, req.userOrgId!) ) ) .limit(1); if (userOrgRole.length === 0) { throw createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ); } userOrgRoleId = userOrgRole[0].roleId; } // Check if the user has direct permission for the action in the current org const userActionPermission = await db .select() .from(userActions) .where( and( eq(userActions.userId, userId), eq(userActions.actionId, actionId), eq(userActions.orgId, req.userOrgId!) // TODO: we cant pass the org id if we are not checking the org ) ) .limit(1); if (userActionPermission.length > 0) { return true; } // If no direct permission, check role-based permission const roleActionPermission = await db .select() .from(roleActions) .where( and( eq(roleActions.actionId, actionId), eq(roleActions.roleId, userOrgRoleId!), eq(roleActions.orgId, req.userOrgId!) ) ) .limit(1); return roleActionPermission.length > 0; } catch (error) { console.error("Error checking user action permission:", error); throw createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error checking action permission" ); } } ================================================ FILE: server/auth/canUserAccessResource.ts ================================================ import { db } from "@server/db"; import { and, eq } from "drizzle-orm"; import { roleResources, userResources } from "@server/db"; export async function canUserAccessResource({ userId, resourceId, roleId }: { userId: string; resourceId: number; roleId: number; }): Promise { const roleResourceAccess = await db .select() .from(roleResources) .where( and( eq(roleResources.resourceId, resourceId), eq(roleResources.roleId, roleId) ) ) .limit(1); if (roleResourceAccess.length > 0) { return true; } const userResourceAccess = await db .select() .from(userResources) .where( and( eq(userResources.userId, userId), eq(userResources.resourceId, resourceId) ) ) .limit(1); if (userResourceAccess.length > 0) { return true; } return false; } ================================================ FILE: server/auth/canUserAccessSiteResource.ts ================================================ 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 { 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; } ================================================ FILE: server/auth/checkValidInvite.ts ================================================ import { db } from "@server/db"; import { UserInvite, userInvites } from "@server/db"; import { isWithinExpirationDate } from "oslo"; import { verifyPassword } from "./password"; import { eq } from "drizzle-orm"; export async function checkValidInvite({ inviteId, token }: { inviteId: string; token: string; }): Promise<{ error?: string; existingInvite?: UserInvite }> { const existingInvite = await db .select() .from(userInvites) .where(eq(userInvites.inviteId, inviteId)) .limit(1); if (!existingInvite.length) { return { error: "Invite ID or token is invalid" }; } if (!isWithinExpirationDate(new Date(existingInvite[0].expiresAt))) { return { error: "Invite has expired" }; } const validToken = await verifyPassword(token, existingInvite[0].tokenHash); if (!validToken) { return { error: "Invite ID or token is invalid" }; } return { existingInvite: existingInvite[0] }; } ================================================ FILE: server/auth/password.ts ================================================ import { hash, verify } from "@node-rs/argon2"; export async function verifyPassword( password: string, hash: string ): Promise { const validPassword = await verify(hash, password, { memoryCost: 19456, timeCost: 2, outputLen: 32, parallelism: 1 }); return validPassword; } export async function hashPassword(password: string): Promise { const passwordHash = await hash(password, { memoryCost: 19456, timeCost: 2, outputLen: 32, parallelism: 1 }); return passwordHash; } ================================================ FILE: server/auth/passwordSchema.ts ================================================ import z from "zod"; export const passwordSchema = z .string() .min(8, { message: "Password must be at least 8 characters long" }) .max(128, { message: "Password must be at most 128 characters long" }) .regex( /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]).*$/, { message: `Your password must meet the following conditions: at least one uppercase English letter, at least one lowercase English letter, at least one digit, at least one special character.` } ); ================================================ FILE: server/auth/resourceOtp.ts ================================================ import { db } from "@server/db"; import { resourceOtp } from "@server/db"; import { and, eq } from "drizzle-orm"; import { createDate, isWithinExpirationDate, TimeSpan } from "oslo"; import { alphabet, generateRandomString, sha256 } from "oslo/crypto"; import { sendEmail } from "@server/emails"; import ResourceOTPCode from "@server/emails/templates/ResourceOTPCode"; import config from "@server/lib/config"; import { verifyPassword } from "./password"; import { hashPassword } from "./password"; export async function sendResourceOtpEmail( email: string, resourceId: number, resourceName: string, orgName: string ): Promise { const otp = await generateResourceOtpCode(resourceId, email); await sendEmail( ResourceOTPCode({ email, resourceName, orgName, otp }), { to: email, from: config.getNoReplyEmail(), subject: `Your one-time code to access ${resourceName}` } ); } export async function generateResourceOtpCode( resourceId: number, email: string ): Promise { const otp = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); await db.transaction(async (trx) => { await trx .delete(resourceOtp) .where( and( eq(resourceOtp.email, email), eq(resourceOtp.resourceId, resourceId) ) ); const otpHash = await hashPassword(otp); await trx.insert(resourceOtp).values({ resourceId, email, otpHash, expiresAt: createDate(new TimeSpan(15, "m")).getTime() }); }); return otp; } export async function isValidOtp( email: string, resourceId: number, otp: string ): Promise { const record = await db .select() .from(resourceOtp) .where( and( eq(resourceOtp.email, email), eq(resourceOtp.resourceId, resourceId) ) ) .limit(1); if (record.length === 0) { return false; } const validCode = await verifyPassword(otp, record[0].otpHash); if (!validCode) { return false; } if (!isWithinExpirationDate(new Date(record[0].expiresAt))) { return false; } return true; } ================================================ FILE: server/auth/sendEmailVerificationCode.ts ================================================ import { TimeSpan, createDate } from "oslo"; import { generateRandomString, alphabet } from "oslo/crypto"; import { db } from "@server/db"; import { users, emailVerificationCodes } from "@server/db"; import { eq } from "drizzle-orm"; import { sendEmail } from "@server/emails"; import config from "@server/lib/config"; import { VerifyEmail } from "@server/emails/templates/VerifyEmailCode"; export async function sendEmailVerificationCode( email: string, userId: string ): Promise { const code = await generateEmailVerificationCode(userId, email); await sendEmail( VerifyEmail({ username: email, verificationCode: code, verifyLink: `${config.getRawConfig().app.dashboard_url}/auth/verify-email` }), { to: email, from: config.getNoReplyEmail(), subject: "Verify your email address" } ); } async function generateEmailVerificationCode( userId: string, email: string ): Promise { const code = generateRandomString(8, alphabet("0-9")); await db.transaction(async (trx) => { await trx .delete(emailVerificationCodes) .where(eq(emailVerificationCodes.userId, userId)); await trx.insert(emailVerificationCodes).values({ userId, email, code, expiresAt: createDate(new TimeSpan(15, "m")).getTime() }); }); return code; } ================================================ FILE: server/auth/sessions/app.ts ================================================ import { encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { resourceSessions, safeRead, Session, sessions, User, users } from "@server/db"; import { db } from "@server/db"; import { eq, inArray } from "drizzle-orm"; import config from "@server/lib/config"; import type { RandomReader } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random"; import logger from "@server/logger"; export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name; export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * config.getRawConfig().server.dashboard_session_length_hours; export const COOKIE_DOMAIN = config.getRawConfig().app.dashboard_url ? new URL(config.getRawConfig().app.dashboard_url!).hostname : undefined; export function generateSessionToken(): string { const bytes = new Uint8Array(20); crypto.getRandomValues(bytes); const token = encodeBase32LowerCaseNoPadding(bytes); return token; } export async function createSession( token: string, userId: string ): Promise { const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const [session] = await db .insert(sessions) .values({ sessionId: sessionId, userId, expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), issuedAt: new Date().getTime() }) .returning(); return session; } export async function validateSessionToken( token: string ): Promise { const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const result = await safeRead((db) => db .select({ user: users, session: sessions }) .from(sessions) .innerJoin(users, eq(sessions.userId, users.userId)) .where(eq(sessions.sessionId, sessionId)) ); if (result.length < 1) { return { session: null, user: null }; } const { user, session } = result[0]; if (Date.now() >= session.expiresAt) { await db .delete(sessions) .where(eq(sessions.sessionId, session.sessionId)); return { session: null, user: null }; } if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) { session.expiresAt = new Date( Date.now() + SESSION_COOKIE_EXPIRES ).getTime(); await db.transaction(async (trx) => { await trx .update(sessions) .set({ expiresAt: session.expiresAt }) .where(eq(sessions.sessionId, session.sessionId)); await trx .update(resourceSessions) .set({ expiresAt: session.expiresAt }) .where(eq(resourceSessions.userSessionId, session.sessionId)); }); } return { session, user }; } export async function invalidateSession(sessionId: string): Promise { try { await db.transaction(async (trx) => { await trx .delete(resourceSessions) .where(eq(resourceSessions.userSessionId, sessionId)); await trx.delete(sessions).where(eq(sessions.sessionId, sessionId)); }); } catch (e) { logger.error("Failed to invalidate session", e); } } export async function invalidateAllSessions(userId: string): Promise { try { await db.transaction(async (trx) => { const userSessions = await trx .select() .from(sessions) .where(eq(sessions.userId, userId)); await trx.delete(resourceSessions).where( inArray( resourceSessions.userSessionId, userSessions.map((s) => s.sessionId) ) ); await trx.delete(sessions).where(eq(sessions.userId, userId)); }); } catch (e) { logger.error("Failed to all invalidate user sessions", e); } } export function serializeSessionCookie( token: string, isSecure: boolean, expiresAt: Date ): string { if (isSecure) { return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; } else { return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/;`; } } export function createBlankSessionTokenCookie(isSecure: boolean): string { if (isSecure) { return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`; } else { return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`; } } const random: RandomReader = { read(bytes: Uint8Array): void { crypto.getRandomValues(bytes); } }; export function generateId(length: number): string { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; return generateRandomString(random, alphabet, length); } export function generateIdFromEntropySize(size: number): string { const buffer = crypto.getRandomValues(new Uint8Array(size)); return encodeBase32LowerCaseNoPadding(buffer); } export type SessionValidationResult = | { session: Session; user: User } | { session: null; user: null }; ================================================ FILE: server/auth/sessions/newt.ts ================================================ import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { Newt, newts, newtSessions, NewtSession } from "@server/db"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; export const EXPIRES = 1000 * 60 * 60 * 24 * 30; export async function createNewtSession( token: string, newtId: string ): Promise { const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const session: NewtSession = { sessionId: sessionId, newtId, expiresAt: new Date(Date.now() + EXPIRES).getTime() }; await db.insert(newtSessions).values(session); return session; } export async function validateNewtSessionToken( token: string ): Promise { const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const result = await db .select({ newt: newts, session: newtSessions }) .from(newtSessions) .innerJoin(newts, eq(newtSessions.newtId, newts.newtId)) .where(eq(newtSessions.sessionId, sessionId)); if (result.length < 1) { return { session: null, newt: null }; } const { newt, session } = result[0]; if (Date.now() >= session.expiresAt) { await db .delete(newtSessions) .where(eq(newtSessions.sessionId, session.sessionId)); return { session: null, newt: null }; } if (Date.now() >= session.expiresAt - EXPIRES / 2) { session.expiresAt = new Date(Date.now() + EXPIRES).getTime(); await db .update(newtSessions) .set({ expiresAt: session.expiresAt }) .where(eq(newtSessions.sessionId, session.sessionId)); } return { session, newt }; } export async function invalidateNewtSession(sessionId: string): Promise { await db.delete(newtSessions).where(eq(newtSessions.sessionId, sessionId)); } export async function invalidateAllNewtSessions(newtId: string): Promise { await db.delete(newtSessions).where(eq(newtSessions.newtId, newtId)); } export type SessionValidationResult = | { session: NewtSession; newt: Newt } | { session: null; newt: null }; ================================================ FILE: server/auth/sessions/olm.ts ================================================ import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { Olm, olms, olmSessions, OlmSession } from "@server/db"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; export const EXPIRES = 1000 * 60 * 60 * 24 * 30; export async function createOlmSession( token: string, olmId: string ): Promise { const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const session: OlmSession = { sessionId: sessionId, olmId, expiresAt: new Date(Date.now() + EXPIRES).getTime() }; await db.insert(olmSessions).values(session); return session; } export async function validateOlmSessionToken( token: string ): Promise { const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const result = await db .select({ olm: olms, session: olmSessions }) .from(olmSessions) .innerJoin(olms, eq(olmSessions.olmId, olms.olmId)) .where(eq(olmSessions.sessionId, sessionId)); if (result.length < 1) { return { session: null, olm: null }; } const { olm, session } = result[0]; if (Date.now() >= session.expiresAt) { await db .delete(olmSessions) .where(eq(olmSessions.sessionId, session.sessionId)); return { session: null, olm: null }; } if (Date.now() >= session.expiresAt - EXPIRES / 2) { session.expiresAt = new Date(Date.now() + EXPIRES).getTime(); await db .update(olmSessions) .set({ expiresAt: session.expiresAt }) .where(eq(olmSessions.sessionId, session.sessionId)); } return { session, olm }; } export async function invalidateOlmSession(sessionId: string): Promise { await db.delete(olmSessions).where(eq(olmSessions.sessionId, sessionId)); } export async function invalidateAllOlmSessions(olmId: string): Promise { await db.delete(olmSessions).where(eq(olmSessions.olmId, olmId)); } export type SessionValidationResult = | { session: OlmSession; olm: Olm } | { session: null; olm: null }; ================================================ FILE: server/auth/sessions/resource.ts ================================================ import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { resourceSessions, ResourceSession } from "@server/db"; import { db, safeRead } from "@server/db"; import { eq, and } from "drizzle-orm"; import config from "@server/lib/config"; export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name; export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours; export async function createResourceSession(opts: { token: string; resourceId: number; isRequestToken?: boolean; passwordId?: number | null; pincodeId?: number | null; userSessionId?: string | null; whitelistId?: number | null; accessTokenId?: string | null; doNotExtend?: boolean; expiresAt?: number | null; sessionLength?: number | null; }): Promise { if ( !opts.passwordId && !opts.pincodeId && !opts.whitelistId && !opts.accessTokenId && !opts.userSessionId ) { throw new Error("Auth method must be provided"); } const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(opts.token)) ); const session: ResourceSession = { sessionId: sessionId, expiresAt: opts.expiresAt || new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(), sessionLength: opts.sessionLength || SESSION_COOKIE_EXPIRES, resourceId: opts.resourceId, passwordId: opts.passwordId || null, pincodeId: opts.pincodeId || null, whitelistId: opts.whitelistId || null, doNotExtend: opts.doNotExtend || false, accessTokenId: opts.accessTokenId || null, isRequestToken: opts.isRequestToken || false, userSessionId: opts.userSessionId || null, issuedAt: new Date().getTime() }; await db.insert(resourceSessions).values(session); return session; } export async function validateResourceSessionToken( token: string, resourceId: number ): Promise { const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const result = await safeRead((db) => db .select() .from(resourceSessions) .where( and( eq(resourceSessions.sessionId, sessionId), eq(resourceSessions.resourceId, resourceId) ) ) ); if (result.length < 1) { return { resourceSession: null }; } const resourceSession = result[0]; if (Date.now() >= resourceSession.expiresAt) { await db .delete(resourceSessions) .where(eq(resourceSessions.sessionId, sessionId)); return { resourceSession: null }; } else if ( Date.now() >= resourceSession.expiresAt - resourceSession.sessionLength / 2 ) { if (!resourceSession.doNotExtend) { resourceSession.expiresAt = new Date( Date.now() + resourceSession.sessionLength ).getTime(); await db .update(resourceSessions) .set({ expiresAt: resourceSession.expiresAt }) .where( eq(resourceSessions.sessionId, resourceSession.sessionId) ); } } return { resourceSession }; } export async function invalidateResourceSession( sessionId: string ): Promise { await db .delete(resourceSessions) .where(eq(resourceSessions.sessionId, sessionId)); } export async function invalidateAllSessions( resourceId: number, method?: { passwordId?: number; pincodeId?: number; whitelistId?: number; } ): Promise { if (method?.passwordId) { await db .delete(resourceSessions) .where( and( eq(resourceSessions.resourceId, resourceId), eq(resourceSessions.passwordId, method.passwordId) ) ); } if (method?.pincodeId) { await db .delete(resourceSessions) .where( and( eq(resourceSessions.resourceId, resourceId), eq(resourceSessions.pincodeId, method.pincodeId) ) ); } if (method?.whitelistId) { await db .delete(resourceSessions) .where( and( eq(resourceSessions.resourceId, resourceId), eq(resourceSessions.whitelistId, method.whitelistId) ) ); } if (!method?.passwordId && !method?.pincodeId && !method?.whitelistId) { await db .delete(resourceSessions) .where(eq(resourceSessions.resourceId, resourceId)); } } export function serializeResourceSessionCookie( cookieName: string, domain: string, token: string, isHttp: boolean = false, expiresAt?: Date ): string { const now = new Date().getTime(); if (!isHttp) { if (expiresAt === undefined) { return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Secure; Domain=${domain}`; } return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${domain}`; } else { if (expiresAt === undefined) { return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${domain}`; } return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${domain}`; } } export function createBlankResourceSessionTokenCookie( cookieName: string, domain: string, isHttp: boolean = false ): string { if (!isHttp) { return `${cookieName}_s=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${domain}`; } else { return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Domain=${domain}`; } } export type ResourceSessionValidationResult = { resourceSession: ResourceSession | null; }; ================================================ FILE: server/auth/sessions/verifySession.ts ================================================ import { Request } from "express"; import { validateSessionToken, SESSION_COOKIE_NAME } from "@server/auth/sessions/app"; export async function verifySession(req: Request, forceLogin?: boolean) { const res = await validateSessionToken( req.cookies[SESSION_COOKIE_NAME] ?? "" ); if (!forceLogin) { return res; } if (!res.session || !res.user) { return { session: null, user: null }; } if (res.session.deviceAuthUsed) { return { session: null, user: null }; } if (!res.session.issuedAt) { return { session: null, user: null }; } const mins = 5 * 60 * 1000; const now = new Date().getTime(); if (now - res.session.issuedAt > mins) { return { session: null, user: null }; } return res; } ================================================ FILE: server/auth/totp.ts ================================================ import { verify } from "@node-rs/argon2"; import { db } from "@server/db"; import { twoFactorBackupCodes } from "@server/db"; import { eq } from "drizzle-orm"; import { decodeHex } from "oslo/encoding"; import { TOTPController } from "oslo/otp"; import { verifyPassword } from "./password"; export async function verifyTotpCode( code: string, secret: string, userId: string ): Promise { // if code is digits only, it's totp const isTotp = /^\d+$/.test(code); if (!isTotp) { const validBackupCode = await verifyBackUpCode(code, userId); return validBackupCode; } else { const validOTP = await new TOTPController().verify( code, decodeHex(secret) ); return validOTP; } } export async function verifyBackUpCode( code: string, userId: string ): Promise { const allHashed = await db .select() .from(twoFactorBackupCodes) .where(eq(twoFactorBackupCodes.userId, userId)); if (!allHashed || !allHashed.length) { return false; } let validId; for (const hashedCode of allHashed) { const validCode = await verifyPassword(code, hashedCode.codeHash); if (validCode) { validId = hashedCode.codeId; } } if (validId) { await db .delete(twoFactorBackupCodes) .where(eq(twoFactorBackupCodes.codeId, validId)); } return validId ? true : false; } ================================================ FILE: server/auth/unauthorizedResponse.ts ================================================ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; export function unauthorized(msg?: string) { return createHttpError(HttpCode.UNAUTHORIZED, msg || "Unauthorized"); } ================================================ FILE: server/auth/verifyResourceAccessToken.ts ================================================ import { db } from "@server/db"; import { Resource, ResourceAccessToken, resourceAccessToken, resources } from "@server/db"; import { and, eq } from "drizzle-orm"; import { isWithinExpirationDate } from "oslo"; import { verifyPassword } from "./password"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; export async function verifyResourceAccessToken({ accessToken, accessTokenId, resourceId }: { accessToken: string; accessTokenId?: string; resourceId?: number; // IF THIS IS NOT SET, THE TOKEN IS VALID FOR ALL RESOURCES }): Promise<{ valid: boolean; error?: string; tokenItem?: ResourceAccessToken; resource?: Resource; }> { const accessTokenHash = encodeHexLowerCase( sha256(new TextEncoder().encode(accessToken)) ); let tokenItem: ResourceAccessToken | undefined; let resource: Resource | undefined; if (!accessTokenId) { const [res] = await db .select() .from(resourceAccessToken) .where(and(eq(resourceAccessToken.tokenHash, accessTokenHash))) .innerJoin( resources, eq(resourceAccessToken.resourceId, resources.resourceId) ); tokenItem = res?.resourceAccessToken; resource = res?.resources; } else { const [res] = await db .select() .from(resourceAccessToken) .where(and(eq(resourceAccessToken.accessTokenId, accessTokenId))) .innerJoin( resources, eq(resourceAccessToken.resourceId, resources.resourceId) ); if (res && res.resourceAccessToken) { if (res.resourceAccessToken.tokenHash?.startsWith("$argon")) { const validCode = await verifyPassword( accessToken, res.resourceAccessToken.tokenHash ); if (!validCode) { return { valid: false, error: "Invalid access token" }; } } else { const tokenHash = encodeHexLowerCase( sha256(new TextEncoder().encode(accessToken)) ); if (res.resourceAccessToken.tokenHash !== tokenHash) { return { valid: false, error: "Invalid access token" }; } } } tokenItem = res?.resourceAccessToken; resource = res?.resources; } if (!tokenItem || !resource) { return { valid: false, error: "Access token does not exist for resource" }; } if ( tokenItem.expiresAt && !isWithinExpirationDate(new Date(tokenItem.expiresAt)) ) { return { valid: false, error: "Access token has expired" }; } if (resourceId && resource.resourceId !== resourceId) { return { valid: false, error: "Resource ID does not match" }; } return { valid: true, tokenItem, resource }; } ================================================ FILE: server/cleanup.ts ================================================ import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage"; import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"; import { cleanup as wsCleanup } from "#dynamic/routers/ws"; async function cleanup() { await flushBandwidthToDb(); await flushSiteBandwidthToDb(); await wsCleanup(); process.exit(0); } export async function initCleanup() { // Handle process termination process.on("SIGTERM", () => cleanup()); process.on("SIGINT", () => cleanup()); } ================================================ FILE: server/db/README.md ================================================ # Database Pangolin can use a Postgres or SQLite database to store its data. ## Development ### Postgres To use Postgres, edit `server/db/index.ts` to export all from `server/db/pg/index.ts`: ```typescript export * from "./pg"; ``` Make sure you have a valid config file with a connection string: ```yaml postgres: connection_string: postgresql://postgres:postgres@localhost:5432 ``` You can run an ephemeral Postgres database for local development using Docker: ```bash docker run -d \ --name postgres \ --rm \ -p 5432:5432 \ -e POSTGRES_PASSWORD=postgres \ -v $(mktemp -d):/var/lib/postgresql/data \ postgres:17 ``` ### Schema `server/db/pg/schema.ts` and `server/db/sqlite/schema.ts` contain the database schema definitions. These need to be kept in sync with with each other. Stick to common data types and avoid Postgres-specific features to ensure compatibility with SQLite. ### SQLite To use SQLite, edit `server/db/index.ts` to export all from `server/db/sqlite/index.ts`: ```typescript export * from "./sqlite"; ``` No edits to the config are needed. If you keep the Postgres config, it will be ignored. ## Generate and Push Migrations Ensure drizzle-kit is installed. ### Postgres You must have a connection string in your config file, as shown above. ```bash npm run db:generate npm run db:push ``` ### SQLite ```bash npm run db:generate npm run db:push ``` ## Build Time There is a dockerfile for each database type. The dockerfile swaps out the `server/db/index.ts` file to use the correct database type. ================================================ FILE: server/db/asns.ts ================================================ // Curated list of major ASNs (Cloud Providers, CDNs, ISPs, etc.) // This is not exhaustive - there are 100,000+ ASNs globally // Users can still enter any ASN manually in the input field export const MAJOR_ASNS = [ { name: "ALL ASNs", code: "ALL", asn: 0 // Special value that will match all }, // Major Cloud Providers { name: "Google LLC", code: "AS15169", asn: 15169 }, { name: "Amazon AWS", code: "AS16509", asn: 16509 }, { name: "Amazon AWS (EC2)", code: "AS14618", asn: 14618 }, { name: "Microsoft Azure", code: "AS8075", asn: 8075 }, { name: "Microsoft Corporation", code: "AS8068", asn: 8068 }, { name: "DigitalOcean", code: "AS14061", asn: 14061 }, { name: "Linode", code: "AS63949", asn: 63949 }, { name: "Hetzner Online", code: "AS24940", asn: 24940 }, { name: "OVH SAS", code: "AS16276", asn: 16276 }, { name: "Oracle Cloud", code: "AS31898", asn: 31898 }, { name: "Alibaba Cloud", code: "AS45102", asn: 45102 }, { name: "IBM Cloud", code: "AS36351", asn: 36351 }, // CDNs { name: "Cloudflare", code: "AS13335", asn: 13335 }, { name: "Fastly", code: "AS54113", asn: 54113 }, { name: "Akamai Technologies", code: "AS20940", asn: 20940 }, { name: "Akamai (Primary)", code: "AS16625", asn: 16625 }, // Mobile Carriers - US { name: "T-Mobile USA", code: "AS21928", asn: 21928 }, { name: "Verizon Wireless", code: "AS6167", asn: 6167 }, { name: "AT&T Mobility", code: "AS20057", asn: 20057 }, { name: "Sprint (T-Mobile)", code: "AS1239", asn: 1239 }, { name: "US Cellular", code: "AS6430", asn: 6430 }, // Mobile Carriers - Europe { name: "Vodafone UK", code: "AS25135", asn: 25135 }, { name: "EE (UK)", code: "AS12576", asn: 12576 }, { name: "Three UK", code: "AS29194", asn: 29194 }, { name: "O2 UK", code: "AS13285", asn: 13285 }, { name: "Telefonica Spain Mobile", code: "AS12430", asn: 12430 }, // Mobile Carriers - Asia { name: "NTT DoCoMo (Japan)", code: "AS9605", asn: 9605 }, { name: "SoftBank Mobile (Japan)", code: "AS17676", asn: 17676 }, { name: "SK Telecom (Korea)", code: "AS9318", asn: 9318 }, { name: "KT Corporation Mobile (Korea)", code: "AS4766", asn: 4766 }, { name: "Airtel India", code: "AS24560", asn: 24560 }, { name: "China Mobile", code: "AS9808", asn: 9808 }, // Major US ISPs { name: "AT&T Services", code: "AS7018", asn: 7018 }, { name: "Comcast Cable", code: "AS7922", asn: 7922 }, { name: "Verizon", code: "AS701", asn: 701 }, { name: "Cox Communications", code: "AS22773", asn: 22773 }, { name: "Charter Communications", code: "AS20115", asn: 20115 }, { name: "CenturyLink", code: "AS209", asn: 209 }, // Major European ISPs { name: "Deutsche Telekom", code: "AS3320", asn: 3320 }, { name: "Vodafone", code: "AS1273", asn: 1273 }, { name: "British Telecom", code: "AS2856", asn: 2856 }, { name: "Orange", code: "AS3215", asn: 3215 }, { name: "Telefonica", code: "AS12956", asn: 12956 }, // Major Asian ISPs { name: "China Telecom", code: "AS4134", asn: 4134 }, { name: "China Unicom", code: "AS4837", asn: 4837 }, { name: "NTT Communications", code: "AS2914", asn: 2914 }, { name: "KDDI Corporation", code: "AS2516", asn: 2516 }, { name: "Reliance Jio (India)", code: "AS55836", asn: 55836 }, // VPN/Proxy Providers { name: "Private Internet Access", code: "AS46562", asn: 46562 }, { name: "NordVPN", code: "AS202425", asn: 202425 }, { name: "Mullvad VPN", code: "AS213281", asn: 213281 }, // Social Media / Major Tech { name: "Facebook/Meta", code: "AS32934", asn: 32934 }, { name: "Twitter/X", code: "AS13414", asn: 13414 }, { name: "Apple", code: "AS714", asn: 714 }, { name: "Netflix", code: "AS2906", asn: 2906 }, // Academic/Research { name: "MIT", code: "AS3", asn: 3 }, { name: "Stanford University", code: "AS32", asn: 32 }, { name: "CERN", code: "AS513", asn: 513 } ]; ================================================ FILE: server/db/countries.ts ================================================ export const COUNTRIES = [ { name: "ALL COUNTRIES", code: "ALL" // THIS IS AN INVALID CC SO IT WILL NEVER MATCH }, { name: "Afghanistan", code: "AF" }, { name: "Albania", code: "AL" }, { name: "Algeria", code: "DZ" }, { name: "American Samoa", code: "AS" }, { name: "Andorra", code: "AD" }, { name: "Angola", code: "AO" }, { name: "Anguilla", code: "AI" }, { name: "Antarctica", code: "AQ" }, { name: "Antigua and Barbuda", code: "AG" }, { name: "Argentina", code: "AR" }, { name: "Armenia", code: "AM" }, { name: "Aruba", code: "AW" }, { name: "Asia/Pacific Region", code: "AP" }, { name: "Australia", code: "AU" }, { name: "Austria", code: "AT" }, { name: "Azerbaijan", code: "AZ" }, { name: "Bahamas", code: "BS" }, { name: "Bahrain", code: "BH" }, { name: "Bangladesh", code: "BD" }, { name: "Barbados", code: "BB" }, { name: "Belarus", code: "BY" }, { name: "Belgium", code: "BE" }, { name: "Belize", code: "BZ" }, { name: "Benin", code: "BJ" }, { name: "Bermuda", code: "BM" }, { name: "Bhutan", code: "BT" }, { name: "Bolivia", code: "BO" }, { name: "Bonaire, Sint Eustatius and Saba", code: "BQ" }, { name: "Bosnia and Herzegovina", code: "BA" }, { name: "Botswana", code: "BW" }, { name: "Bouvet Island", code: "BV" }, { name: "Brazil", code: "BR" }, { name: "British Indian Ocean Territory", code: "IO" }, { name: "Brunei Darussalam", code: "BN" }, { name: "Bulgaria", code: "BG" }, { name: "Burkina Faso", code: "BF" }, { name: "Burundi", code: "BI" }, { name: "Cambodia", code: "KH" }, { name: "Cameroon", code: "CM" }, { name: "Canada", code: "CA" }, { name: "Cape Verde", code: "CV" }, { name: "Cayman Islands", code: "KY" }, { name: "Central African Republic", code: "CF" }, { name: "Chad", code: "TD" }, { name: "Chile", code: "CL" }, { name: "China", code: "CN" }, { name: "Christmas Island", code: "CX" }, { name: "Cocos (Keeling) Islands", code: "CC" }, { name: "Colombia", code: "CO" }, { name: "Comoros", code: "KM" }, { name: "Congo", code: "CG" }, { name: "Congo, The Democratic Republic of the", code: "CD" }, { name: "Cook Islands", code: "CK" }, { name: "Costa Rica", code: "CR" }, { name: "Croatia", code: "HR" }, { name: "Cuba", code: "CU" }, { name: "Curaçao", code: "CW" }, { name: "Cyprus", code: "CY" }, { name: "Czech Republic", code: "CZ" }, { name: "Côte d'Ivoire", code: "CI" }, { name: "Denmark", code: "DK" }, { name: "Djibouti", code: "DJ" }, { name: "Dominica", code: "DM" }, { name: "Dominican Republic", code: "DO" }, { name: "Ecuador", code: "EC" }, { name: "Egypt", code: "EG" }, { name: "El Salvador", code: "SV" }, { name: "Equatorial Guinea", code: "GQ" }, { name: "Eritrea", code: "ER" }, { name: "Estonia", code: "EE" }, { name: "Ethiopia", code: "ET" }, { name: "Falkland Islands (Malvinas)", code: "FK" }, { name: "Faroe Islands", code: "FO" }, { name: "Fiji", code: "FJ" }, { name: "Finland", code: "FI" }, { name: "France", code: "FR" }, { name: "French Guiana", code: "GF" }, { name: "French Polynesia", code: "PF" }, { name: "French Southern Territories", code: "TF" }, { name: "Gabon", code: "GA" }, { name: "Gambia", code: "GM" }, { name: "Georgia", code: "GE" }, { name: "Germany", code: "DE" }, { name: "Ghana", code: "GH" }, { name: "Gibraltar", code: "GI" }, { name: "Greece", code: "GR" }, { name: "Greenland", code: "GL" }, { name: "Grenada", code: "GD" }, { name: "Guadeloupe", code: "GP" }, { name: "Guam", code: "GU" }, { name: "Guatemala", code: "GT" }, { name: "Guernsey", code: "GG" }, { name: "Guinea", code: "GN" }, { name: "Guinea-Bissau", code: "GW" }, { name: "Guyana", code: "GY" }, { name: "Haiti", code: "HT" }, { name: "Heard Island and Mcdonald Islands", code: "HM" }, { name: "Holy See (Vatican City State)", code: "VA" }, { name: "Honduras", code: "HN" }, { name: "Hong Kong", code: "HK" }, { name: "Hungary", code: "HU" }, { name: "Iceland", code: "IS" }, { name: "India", code: "IN" }, { name: "Indonesia", code: "ID" }, { name: "Iran, Islamic Republic Of", code: "IR" }, { name: "Iraq", code: "IQ" }, { name: "Ireland", code: "IE" }, { name: "Isle of Man", code: "IM" }, { name: "Israel", code: "IL" }, { name: "Italy", code: "IT" }, { name: "Jamaica", code: "JM" }, { name: "Japan", code: "JP" }, { name: "Jersey", code: "JE" }, { name: "Jordan", code: "JO" }, { name: "Kazakhstan", code: "KZ" }, { name: "Kenya", code: "KE" }, { name: "Kiribati", code: "KI" }, { name: "Korea, Republic of", code: "KR" }, { name: "Kuwait", code: "KW" }, { name: "Kyrgyzstan", code: "KG" }, { name: "Laos", code: "LA" }, { name: "Latvia", code: "LV" }, { name: "Lebanon", code: "LB" }, { name: "Lesotho", code: "LS" }, { name: "Liberia", code: "LR" }, { name: "Libyan Arab Jamahiriya", code: "LY" }, { name: "Liechtenstein", code: "LI" }, { name: "Lithuania", code: "LT" }, { name: "Luxembourg", code: "LU" }, { name: "Macao", code: "MO" }, { name: "Madagascar", code: "MG" }, { name: "Malawi", code: "MW" }, { name: "Malaysia", code: "MY" }, { name: "Maldives", code: "MV" }, { name: "Mali", code: "ML" }, { name: "Malta", code: "MT" }, { name: "Marshall Islands", code: "MH" }, { name: "Martinique", code: "MQ" }, { name: "Mauritania", code: "MR" }, { name: "Mauritius", code: "MU" }, { name: "Mayotte", code: "YT" }, { name: "Mexico", code: "MX" }, { name: "Micronesia, Federated States of", code: "FM" }, { name: "Moldova, Republic of", code: "MD" }, { name: "Monaco", code: "MC" }, { name: "Mongolia", code: "MN" }, { name: "Montenegro", code: "ME" }, { name: "Montserrat", code: "MS" }, { name: "Morocco", code: "MA" }, { name: "Mozambique", code: "MZ" }, { name: "Myanmar", code: "MM" }, { name: "Namibia", code: "NA" }, { name: "Nauru", code: "NR" }, { name: "Nepal", code: "NP" }, { name: "Netherlands", code: "NL" }, { name: "Netherlands Antilles", code: "AN" }, { name: "New Caledonia", code: "NC" }, { name: "New Zealand", code: "NZ" }, { name: "Nicaragua", code: "NI" }, { name: "Niger", code: "NE" }, { name: "Nigeria", code: "NG" }, { name: "Niue", code: "NU" }, { name: "Norfolk Island", code: "NF" }, { name: "North Korea", code: "KP" }, { name: "North Macedonia", code: "MK" }, { name: "Northern Mariana Islands", code: "MP" }, { name: "Norway", code: "NO" }, { name: "Oman", code: "OM" }, { name: "Pakistan", code: "PK" }, { name: "Palau", code: "PW" }, { name: "Palestinian Territory, Occupied", code: "PS" }, { name: "Panama", code: "PA" }, { name: "Papua New Guinea", code: "PG" }, { name: "Paraguay", code: "PY" }, { name: "Peru", code: "PE" }, { name: "Philippines", code: "PH" }, { name: "Pitcairn Islands", code: "PN" }, { name: "Poland", code: "PL" }, { name: "Portugal", code: "PT" }, { name: "Puerto Rico", code: "PR" }, { name: "Qatar", code: "QA" }, { name: "Reunion", code: "RE" }, { name: "Romania", code: "RO" }, { name: "Russian Federation", code: "RU" }, { name: "Rwanda", code: "RW" }, { name: "Saint Barthélemy", code: "BL" }, { name: "Saint Helena", code: "SH" }, { name: "Saint Kitts and Nevis", code: "KN" }, { name: "Saint Lucia", code: "LC" }, { name: "Saint Martin", code: "MF" }, { name: "Saint Pierre and Miquelon", code: "PM" }, { name: "Saint Vincent and the Grenadines", code: "VC" }, { name: "Samoa", code: "WS" }, { name: "San Marino", code: "SM" }, { name: "Sao Tome and Principe", code: "ST" }, { name: "Saudi Arabia", code: "SA" }, { name: "Senegal", code: "SN" }, { name: "Serbia", code: "RS" }, { name: "Serbia and Montenegro", code: "CS" }, { name: "Seychelles", code: "SC" }, { name: "Sierra Leone", code: "SL" }, { name: "Singapore", code: "SG" }, { name: "Sint Maarten", code: "SX" }, { name: "Slovakia", code: "SK" }, { name: "Slovenia", code: "SI" }, { name: "Solomon Islands", code: "SB" }, { name: "Somalia", code: "SO" }, { name: "South Africa", code: "ZA" }, { name: "South Georgia and the South Sandwich Islands", code: "GS" }, { name: "South Sudan", code: "SS" }, { name: "Spain", code: "ES" }, { name: "Sri Lanka", code: "LK" }, { name: "Sudan", code: "SD" }, { name: "Suriname", code: "SR" }, { name: "Svalbard and Jan Mayen", code: "SJ" }, { name: "Swaziland", code: "SZ" }, { name: "Sweden", code: "SE" }, { name: "Switzerland", code: "CH" }, { name: "Syrian Arab Republic", code: "SY" }, { name: "Taiwan", code: "TW" }, { name: "Tajikistan", code: "TJ" }, { name: "Tanzania, United Republic of", code: "TZ" }, { name: "Thailand", code: "TH" }, { name: "Timor-Leste", code: "TL" }, { name: "Togo", code: "TG" }, { name: "Tokelau", code: "TK" }, { name: "Tonga", code: "TO" }, { name: "Trinidad and Tobago", code: "TT" }, { name: "Tunisia", code: "TN" }, { name: "Turkey", code: "TR" }, { name: "Turkmenistan", code: "TM" }, { name: "Turks and Caicos Islands", code: "TC" }, { name: "Tuvalu", code: "TV" }, { name: "Uganda", code: "UG" }, { name: "Ukraine", code: "UA" }, { name: "United Arab Emirates", code: "AE" }, { name: "United Kingdom", code: "GB" }, { name: "United States", code: "US" }, { name: "United States Minor Outlying Islands", code: "UM" }, { name: "Uruguay", code: "UY" }, { name: "Uzbekistan", code: "UZ" }, { name: "Vanuatu", code: "VU" }, { name: "Venezuela", code: "VE" }, { name: "Vietnam", code: "VN" }, { name: "Virgin Islands, British", code: "VG" }, { name: "Virgin Islands, U.S.", code: "VI" }, { name: "Wallis and Futuna", code: "WF" }, { name: "Western Sahara", code: "EH" }, { name: "Yemen", code: "YE" }, { name: "Zambia", code: "ZM" }, { name: "Zimbabwe", code: "ZW" }, { name: "Åland Islands", code: "AX" } ]; ================================================ FILE: server/db/ios_models.json ================================================ { "iPad1,1": "iPad", "iPad2,1": "iPad 2", "iPad2,2": "iPad 2", "iPad2,3": "iPad 2", "iPad2,4": "iPad 2", "iPad3,1": "iPad 3rd Gen", "iPad3,3": "iPad 3rd Gen", "iPad3,2": "iPad 3rd Gen", "iPad3,4": "iPad 4th Gen", "iPad3,5": "iPad 4th Gen", "iPad3,6": "iPad 4th Gen", "iPad6,11": "iPad 9.7 5th Gen", "iPad6,12": "iPad 9.7 5th Gen", "iPad7,5": "iPad 9.7 6th Gen", "iPad7,6": "iPad 9.7 6th Gen", "iPad7,11": "iPad 10.2 7th Gen", "iPad7,12": "iPad 10.2 7th Gen", "iPad11,6": "iPad 10.2 8th Gen", "iPad11,7": "iPad 10.2 8th Gen", "iPad12,1": "iPad 10.2 9th Gen", "iPad12,2": "iPad 10.2 9th Gen", "iPad13,18": "iPad 10.9 10th Gen", "iPad13,19": "iPad 10.9 10th Gen", "iPad4,1": "iPad Air", "iPad4,2": "iPad Air", "iPad4,3": "iPad Air", "iPad5,3": "iPad Air 2", "iPad5,4": "iPad Air 2", "iPad11,3": "iPad Air 3rd Gen", "iPad11,4": "iPad Air 3rd Gen", "iPad13,1": "iPad Air 4th Gen", "iPad13,2": "iPad Air 4th Gen", "iPad13,16": "iPad Air 5th Gen", "iPad13,17": "iPad Air 5th Gen", "iPad14,8": "iPad Air M2 11", "iPad14,9": "iPad Air M2 11", "iPad14,10": "iPad Air M2 13", "iPad14,11": "iPad Air M2 13", "iPad2,5": "iPad mini", "iPad2,6": "iPad mini", "iPad2,7": "iPad mini", "iPad4,4": "iPad mini 2", "iPad4,5": "iPad mini 2", "iPad4,6": "iPad mini 2", "iPad4,7": "iPad mini 3", "iPad4,8": "iPad mini 3", "iPad4,9": "iPad mini 3", "iPad5,1": "iPad mini 4", "iPad5,2": "iPad mini 4", "iPad11,1": "iPad mini 5th Gen", "iPad11,2": "iPad mini 5th Gen", "iPad14,1": "iPad mini 6th Gen", "iPad14,2": "iPad mini 6th Gen", "iPad6,7": "iPad Pro 12.9", "iPad6,8": "iPad Pro 12.9", "iPad6,3": "iPad Pro 9.7", "iPad6,4": "iPad Pro 9.7", "iPad7,3": "iPad Pro 10.5", "iPad7,4": "iPad Pro 10.5", "iPad7,1": "iPad Pro 12.9", "iPad7,2": "iPad Pro 12.9", "iPad8,1": "iPad Pro 11", "iPad8,2": "iPad Pro 11", "iPad8,3": "iPad Pro 11", "iPad8,4": "iPad Pro 11", "iPad8,5": "iPad Pro 12.9", "iPad8,6": "iPad Pro 12.9", "iPad8,7": "iPad Pro 12.9", "iPad8,8": "iPad Pro 12.9", "iPad8,9": "iPad Pro 11", "iPad8,10": "iPad Pro 11", "iPad8,11": "iPad Pro 12.9", "iPad8,12": "iPad Pro 12.9", "iPad13,4": "iPad Pro 11", "iPad13,5": "iPad Pro 11", "iPad13,6": "iPad Pro 11", "iPad13,7": "iPad Pro 11", "iPad13,8": "iPad Pro 12.9", "iPad13,9": "iPad Pro 12.9", "iPad13,10": "iPad Pro 12.9", "iPad13,11": "iPad Pro 12.9", "iPad14,3": "iPad Pro 11", "iPad14,4": "iPad Pro 11", "iPad14,5": "iPad Pro 12.9", "iPad14,6": "iPad Pro 12.9", "iPad16,3": "iPad Pro M4 11", "iPad16,4": "iPad Pro M4 11", "iPad16,5": "iPad Pro M4 13", "iPad16,6": "iPad Pro M4 13", "iPhone1,1": "iPhone", "iPhone1,2": "iPhone 3G", "iPhone2,1": "iPhone 3GS", "iPhone3,1": "iPhone 4", "iPhone3,2": "iPhone 4", "iPhone3,3": "iPhone 4", "iPhone4,1": "iPhone 4S", "iPhone5,1": "iPhone 5", "iPhone5,2": "iPhone 5", "iPhone5,3": "iPhone 5c", "iPhone5,4": "iPhone 5c", "iPhone6,1": "iPhone 5s", "iPhone6,2": "iPhone 5s", "iPhone7,2": "iPhone 6", "iPhone7,1": "iPhone 6 Plus", "iPhone8,1": "iPhone 6s", "iPhone8,2": "iPhone 6s Plus", "iPhone8,4": "iPhone SE", "iPhone9,1": "iPhone 7", "iPhone9,3": "iPhone 7", "iPhone9,2": "iPhone 7 Plus", "iPhone9,4": "iPhone 7 Plus", "iPhone10,1": "iPhone 8", "iPhone10,4": "iPhone 8", "iPhone10,2": "iPhone 8 Plus", "iPhone10,5": "iPhone 8 Plus", "iPhone10,3": "iPhone X", "iPhone10,6": "iPhone X", "iPhone11,2": "iPhone Xs", "iPhone11,6": "iPhone Xs Max", "iPhone11,8": "iPhone XR", "iPhone12,1": "iPhone 11", "iPhone12,3": "iPhone 11 Pro", "iPhone12,5": "iPhone 11 Pro Max", "iPhone12,8": "iPhone SE", "iPhone13,1": "iPhone 12 mini", "iPhone13,2": "iPhone 12", "iPhone13,3": "iPhone 12 Pro", "iPhone13,4": "iPhone 12 Pro Max", "iPhone14,4": "iPhone 13 mini", "iPhone14,5": "iPhone 13", "iPhone14,2": "iPhone 13 Pro", "iPhone14,3": "iPhone 13 Pro Max", "iPhone14,6": "iPhone SE", "iPhone14,7": "iPhone 14", "iPhone14,8": "iPhone 14 Plus", "iPhone15,2": "iPhone 14 Pro", "iPhone15,3": "iPhone 14 Pro Max", "iPhone15,4": "iPhone 15", "iPhone15,5": "iPhone 15 Plus", "iPhone16,1": "iPhone 15 Pro", "iPhone16,2": "iPhone 15 Pro Max", "iPod1,1": "iPod touch Original", "iPod2,1": "iPod touch 2nd", "iPod3,1": "iPod touch 3rd Gen", "iPod4,1": "iPod touch 4th", "iPod5,1": "iPod touch 5th", "iPod7,1": "iPod touch 6th Gen", "iPod9,1": "iPod touch 7th Gen" } ================================================ FILE: server/db/mac_models.json ================================================ { "PowerMac4,4": "eMac", "PowerMac6,4": "eMac", "PowerBook2,1": "iBook", "PowerBook2,2": "iBook", "PowerBook4,1": "iBook", "PowerBook4,2": "iBook", "PowerBook4,3": "iBook", "PowerBook6,3": "iBook", "PowerBook6,5": "iBook", "PowerBook6,7": "iBook", "iMac,1": "iMac", "PowerMac2,1": "iMac", "PowerMac2,2": "iMac", "PowerMac4,1": "iMac", "PowerMac4,2": "iMac", "PowerMac4,5": "iMac", "PowerMac6,1": "iMac", "PowerMac6,3*": "iMac", "PowerMac6,3": "iMac", "PowerMac8,1": "iMac", "PowerMac8,2": "iMac", "PowerMac12,1": "iMac", "iMac4,1": "iMac", "iMac4,2": "iMac", "iMac5,2": "iMac", "iMac5,1": "iMac", "iMac6,1": "iMac", "iMac7,1": "iMac", "iMac8,1": "iMac", "iMac9,1": "iMac", "iMac10,1": "iMac", "iMac11,1": "iMac", "iMac11,2": "iMac", "iMac11,3": "iMac", "iMac12,1": "iMac", "iMac12,2": "iMac", "iMac13,1": "iMac", "iMac13,2": "iMac", "iMac14,1": "iMac", "iMac14,3": "iMac", "iMac14,2": "iMac", "iMac14,4": "iMac", "iMac15,1": "iMac", "iMac16,1": "iMac", "iMac16,2": "iMac", "iMac17,1": "iMac", "iMac18,1": "iMac", "iMac18,2": "iMac", "iMac18,3": "iMac", "iMac19,2": "iMac", "iMac19,1": "iMac", "iMac20,1": "iMac", "iMac20,2": "iMac", "iMac21,2": "iMac", "iMac21,1": "iMac", "iMacPro1,1": "iMac Pro", "PowerMac10,1": "Mac mini", "PowerMac10,2": "Mac mini", "Macmini1,1": "Mac mini", "Macmini2,1": "Mac mini", "Macmini3,1": "Mac mini", "Macmini4,1": "Mac mini", "Macmini5,1": "Mac mini", "Macmini5,2": "Mac mini", "Macmini5,3": "Mac mini", "Macmini6,1": "Mac mini", "Macmini6,2": "Mac mini", "Macmini7,1": "Mac mini", "Macmini8,1": "Mac mini", "ADP3,2": "Mac mini", "Macmini9,1": "Mac mini", "Mac14,3": "Mac mini", "Mac14,12": "Mac mini", "MacPro1,1*": "Mac Pro", "MacPro2,1": "Mac Pro", "MacPro3,1": "Mac Pro", "MacPro4,1": "Mac Pro", "MacPro5,1": "Mac Pro", "MacPro6,1": "Mac Pro", "MacPro7,1": "Mac Pro", "N/A*": "Power Macintosh", "PowerMac1,1": "Power Macintosh", "PowerMac3,1": "Power Macintosh", "PowerMac3,3": "Power Macintosh", "PowerMac3,4": "Power Macintosh", "PowerMac3,5": "Power Macintosh", "PowerMac3,6": "Power Macintosh", "Mac13,1": "Mac Studio", "Mac13,2": "Mac Studio", "MacBook1,1": "MacBook", "MacBook2,1": "MacBook", "MacBook3,1": "MacBook", "MacBook4,1": "MacBook", "MacBook5,1": "MacBook", "MacBook5,2": "MacBook", "MacBook6,1": "MacBook", "MacBook7,1": "MacBook", "MacBook8,1": "MacBook", "MacBook9,1": "MacBook", "MacBook10,1": "MacBook", "MacBookAir1,1": "MacBook Air", "MacBookAir2,1": "MacBook Air", "MacBookAir3,1": "MacBook Air", "MacBookAir3,2": "MacBook Air", "MacBookAir4,1": "MacBook Air", "MacBookAir4,2": "MacBook Air", "MacBookAir5,1": "MacBook Air", "MacBookAir5,2": "MacBook Air", "MacBookAir6,1": "MacBook Air", "MacBookAir6,2": "MacBook Air", "MacBookAir7,1": "MacBook Air", "MacBookAir7,2": "MacBook Air", "MacBookAir8,1": "MacBook Air", "MacBookAir8,2": "MacBook Air", "MacBookAir9,1": "MacBook Air", "MacBookAir10,1": "MacBook Air", "Mac14,2": "MacBook Air", "MacBookPro1,1": "MacBook Pro", "MacBookPro1,2": "MacBook Pro", "MacBookPro2,2": "MacBook Pro", "MacBookPro2,1": "MacBook Pro", "MacBookPro3,1": "MacBook Pro", "MacBookPro4,1": "MacBook Pro", "MacBookPro5,1": "MacBook Pro", "MacBookPro5,2": "MacBook Pro", "MacBookPro5,5": "MacBook Pro", "MacBookPro5,4": "MacBook Pro", "MacBookPro5,3": "MacBook Pro", "MacBookPro7,1": "MacBook Pro", "MacBookPro6,2": "MacBook Pro", "MacBookPro6,1": "MacBook Pro", "MacBookPro8,1": "MacBook Pro", "MacBookPro8,2": "MacBook Pro", "MacBookPro8,3": "MacBook Pro", "MacBookPro9,2": "MacBook Pro", "MacBookPro9,1": "MacBook Pro", "MacBookPro10,1": "MacBook Pro", "MacBookPro10,2": "MacBook Pro", "MacBookPro11,1": "MacBook Pro", "MacBookPro11,2": "MacBook Pro", "MacBookPro11,3": "MacBook Pro", "MacBookPro12,1": "MacBook Pro", "MacBookPro11,4": "MacBook Pro", "MacBookPro11,5": "MacBook Pro", "MacBookPro13,1": "MacBook Pro", "MacBookPro13,2": "MacBook Pro", "MacBookPro13,3": "MacBook Pro", "MacBookPro14,1": "MacBook Pro", "MacBookPro14,2": "MacBook Pro", "MacBookPro14,3": "MacBook Pro", "MacBookPro15,2": "MacBook Pro", "MacBookPro15,1": "MacBook Pro", "MacBookPro15,3": "MacBook Pro", "MacBookPro15,4": "MacBook Pro", "MacBookPro16,1": "MacBook Pro", "MacBookPro16,3": "MacBook Pro", "MacBookPro16,2": "MacBook Pro", "MacBookPro16,4": "MacBook Pro", "MacBookPro17,1": "MacBook Pro", "MacBookPro18,3": "MacBook Pro", "MacBookPro18,4": "MacBook Pro", "MacBookPro18,1": "MacBook Pro", "MacBookPro18,2": "MacBook Pro", "Mac14,7": "MacBook Pro", "Mac14,9": "MacBook Pro", "Mac14,5": "MacBook Pro", "Mac14,10": "MacBook Pro", "Mac14,6": "MacBook Pro", "PowerMac1,2": "Power Macintosh", "PowerMac5,1": "Power Macintosh", "PowerMac7,2": "Power Macintosh", "PowerMac7,3": "Power Macintosh", "PowerMac9,1": "Power Macintosh", "PowerMac11,2": "Power Macintosh", "PowerBook1,1": "PowerBook", "PowerBook3,1": "PowerBook", "PowerBook3,2": "PowerBook", "PowerBook3,3": "PowerBook", "PowerBook3,4": "PowerBook", "PowerBook3,5": "PowerBook", "PowerBook6,1": "PowerBook", "PowerBook5,1": "PowerBook", "PowerBook6,2": "PowerBook", "PowerBook5,2": "PowerBook", "PowerBook5,3": "PowerBook", "PowerBook6,4": "PowerBook", "PowerBook5,4": "PowerBook", "PowerBook5,5": "PowerBook", "PowerBook6,8": "PowerBook", "PowerBook5,6": "PowerBook", "PowerBook5,7": "PowerBook", "PowerBook5,8": "PowerBook", "PowerBook5,9": "PowerBook", "RackMac1,1": "Xserve", "RackMac1,2": "Xserve", "RackMac3,1": "Xserve", "Xserve1,1": "Xserve", "Xserve2,1": "Xserve", "Xserve3,1": "Xserve" } ================================================ FILE: server/db/maxmind.ts ================================================ import maxmind, { CountryResponse, Reader } from "maxmind"; import config from "@server/lib/config"; let maxmindLookup: Reader | null; if (config.getRawConfig().server.maxmind_db_path) { maxmindLookup = await maxmind.open( config.getRawConfig().server.maxmind_db_path! ); } else { maxmindLookup = null; } export { maxmindLookup }; ================================================ FILE: server/db/maxmindAsn.ts ================================================ import maxmind, { AsnResponse, Reader } from "maxmind"; import config from "@server/lib/config"; let maxmindAsnLookup: Reader | null; if (config.getRawConfig().server.maxmind_asn_path) { maxmindAsnLookup = await maxmind.open( config.getRawConfig().server.maxmind_asn_path! ); } else { maxmindAsnLookup = null; } export { maxmindAsnLookup }; ================================================ FILE: server/db/migrate.ts ================================================ import { runMigrations } from "./"; await runMigrations(); ================================================ FILE: server/db/names.json ================================================ { "descriptors": [ "abandoned", "able", "absolute", "adorable", "adventurous", "academic", "acceptable", "acclaimed", "accomplished", "accurate", "aching", "acidic", "acrobatic", "active", "actual", "adept", "admirable", "admired", "adolescent", "adorable", "adored", "advanced", "afraid", "affectionate", "aged", "aggravating", "aggressive", "agile", "agitated", "agonizing", "agreeable", "ajar", "alarmed", "alarming", "alert", "alienated", "alive", "all", "altruistic", "amazing", "ambitious", "ample", "amused", "amusing", "anchored", "ancient", "angelic", "angry", "anguished", "animated", "annual", "another", "antique", "anxious", "any", "apprehensive", "appropriate", "apt", "arctic", "arid", "aromatic", "artistic", "ashamed", "assured", "astonishing", "athletic", "attached", "attentive", "attractive", "austere", "authentic", "authorized", "automatic", "avaricious", "average", "aware", "awesome", "awful", "awkward", "babyish", "bad", "back", "baggy", "bare", "barren", "basic", "beautiful", "belated", "beloved", "beneficial", "better", "best", "bewitched", "big", "big-hearted", "biodegradable", "bite-sized", "bitter", "black", "black-and-white", "bland", "blank", "blaring", "bleak", "blind", "blissful", "blond", "blue", "blushing", "bogus", "boiling", "bold", "bony", "boring", "bossy", "both", "bouncy", "bountiful", "bowed", "brave", "breakable", "brief", "bright", "brilliant", "brisk", "broken", "bronze", "brown", "bruised", "bubbly", "bulky", "bumpy", "buoyant", "burdensome", "burly", "bustling", "busy", "buttery", "buzzing", "calculating", "calm", "candid", "canine", "capital", "carefree", "careful", "careless", "caring", "cautious", "cavernous", "celebrated", "charming", "cheap", "cheerful", "cheery", "chief", "chilly", "chubby", "circular", "classic", "clean", "clear", "clear-cut", "clever", "close", "closed", "cloudy", "clueless", "clumsy", "cluttered", "coarse", "cold", "colorful", "colorless", "colossal", "comfortable", "common", "compassionate", "competent", "complete", "complex", "complicated", "composed", "concerned", "concrete", "confused", "conscious", "considerate", "constant", "content", "conventional", "cooked", "cool", "cooperative", "coordinated", "corny", "corrupt", "costly", "courageous", "courteous", "crafty", "crazy", "creamy", "creative", "creepy", "criminal", "crisp", "critical", "crooked", "crowded", "cruel", "crushing", "cuddly", "cultivated", "cultured", "cumbersome", "curly", "curvy", "cute", "cylindrical", "damaged", "damp", "dangerous", "dapper", "daring", "darling", "dark", "dazzling", "dead", "deadly", "deafening", "dear", "dearest", "decent", "decimal", "decisive", "deep", "defenseless", "defensive", "defiant", "deficient", "definite", "definitive", "delayed", "delectable", "delicious", "delightful", "delirious", "demanding", "dense", "dental", "dependable", "dependent", "descriptive", "deserted", "detailed", "determined", "devoted", "different", "difficult", "digital", "diligent", "dim", "dimpled", "dimwitted", "direct", "disastrous", "discrete", "disfigured", "disgusting", "disloyal", "dismal", "distant", "downright", "dreary", "dirty", "disguised", "dishonest", "dismal", "distant", "distinct", "distorted", "dizzy", "dopey", "doting", "double", "downright", "drab", "drafty", "dramatic", "dreary", "droopy", "dry", "dual", "dull", "dutiful", "each", "eager", "earnest", "early", "easy", "easy-going", "ecstatic", "edible", "educated", "elaborate", "elastic", "elated", "elderly", "electric", "elegant", "elementary", "elliptical", "embarrassed", "embellished", "eminent", "emotional", "empty", "enchanted", "enchanting", "energetic", "enlightened", "enormous", "enraged", "entire", "envious", "equal", "equatorial", "essential", "esteemed", "ethical", "euphoric", "even", "evergreen", "everlasting", "every", "evil", "exalted", "excellent", "exemplary", "exhausted", "excitable", "excited", "exciting", "exotic", "expensive", "experienced", "expert", "extraneous", "extroverted", "extra-large", "extra-small", "fabulous", "failing", "faint", "fair", "faithful", "fake", "false", "familiar", "famous", "fancy", "fantastic", "far", "faraway", "far-flung", "far-off", "fast", "fat", "fatal", "fatherly", "favorable", "favorite", "fearful", "fearless", "feisty", "feline", "female", "feminine", "few", "fickle", "filthy", "fine", "finished", "firm", "first", "firsthand", "fitting", "fixed", "flaky", "flamboyant", "flashy", "flat", "flawed", "flawless", "flickering", "flimsy", "flippant", "flowery", "fluffy", "fluid", "flustered", "focused", "fond", "foolhardy", "foolish", "forceful", "forked", "formal", "forsaken", "forthright", "fortunate", "fragrant", "frail", "frank", "frayed", "free", "French", "fresh", "frequent", "friendly", "frightened", "frightening", "frigid", "frilly", "frizzy", "frivolous", "front", "frosty", "frozen", "frugal", "fruitful", "full", "fumbling", "functional", "funny", "fussy", "fuzzy", "gargantuan", "gaseous", "general", "generous", "gentle", "genuine", "giant", "giddy", "gigantic", "gifted", "giving", "glamorous", "glaring", "glass", "gleaming", "gleeful", "glistening", "glittering", "gloomy", "glorious", "glossy", "glum", "golden", "good", "good-natured", "gorgeous", "graceful", "gracious", "grand", "grandiose", "granular", "grateful", "grave", "gray", "great", "greedy", "green", "gregarious", "grim", "grimy", "gripping", "grizzled", "gross", "grotesque", "grouchy", "grounded", "growing", "growling", "grown", "grubby", "gruesome", "grumpy", "guilty", "gullible", "gummy", "hairy", "half", "handmade", "handsome", "handy", "happy", "happy-go-lucky", "hard", "hard-to-find", "harmful", "harmless", "harmonious", "harsh", "hasty", "hateful", "haunting", "healthy", "heartfelt", "hearty", "heavenly", "heavy", "hefty", "helpful", "helpless", "hidden", "hideous", "high", "high-level", "hilarious", "hoarse", "hollow", "homely", "honest", "honorable", "honored", "hopeful", "horrible", "hospitable", "hot", "huge", "humble", "humiliating", "humming", "humongous", "hungry", "hurtful", "husky", "icky", "icy", "ideal", "idealistic", "identical", "idle", "idiotic", "idolized", "ignorant", "ill", "illegal", "ill-fated", "ill-informed", "illiterate", "illustrious", "imaginary", "imaginative", "immaculate", "immaterial", "immediate", "immense", "impassioned", "impeccable", "impartial", "imperfect", "imperturbable", "impish", "impolite", "important", "impossible", "impractical", "impressionable", "impressive", "improbable", "impure", "inborn", "incomparable", "incompatible", "incomplete", "inconsequential", "incredible", "indelible", "inexperienced", "indolent", "infamous", "infantile", "infatuated", "inferior", "infinite", "informal", "innocent", "insecure", "insidious", "insignificant", "insistent", "instructive", "insubstantial", "intelligent", "intent", "intentional", "interesting", "internal", "international", "intrepid", "ironclad", "irresponsible", "irritating", "itchy", "jaded", "jagged", "jam-packed", "jaunty", "jealous", "jittery", "joint", "jolly", "jovial", "joyful", "joyous", "jubilant", "judicious", "juicy", "jumbo", "junior", "jumpy", "juvenile", "kaleidoscopic", "keen", "key", "kind", "kindhearted", "kindly", "klutzy", "knobby", "knotty", "knowledgeable", "knowing", "known", "kooky", "kosher", "lame", "lanky", "large", "last", "lasting", "late", "lavish", "lawful", "lazy", "leading", "lean", "leafy", "left", "legal", "legitimate", "light", "lighthearted", "likable", "likely", "limited", "limp", "limping", "linear", "lined", "liquid", "little", "live", "lively", "livid", "loathsome", "lone", "lonely", "long", "long-term", "loose", "lopsided", "lost", "loud", "lovable", "lovely", "loving", "low", "loyal", "lucky", "lumbering", "luminous", "lumpy", "lustrous", "luxurious", "mad", "made-up", "magnificent", "majestic", "major", "male", "mammoth", "married", "marvelous", "masculine", "massive", "mature", "meager", "mealy", "mean", "measly", "meaty", "medical", "mediocre", "medium", "meek", "mellow", "melodic", "memorable", "menacing", "merry", "messy", "metallic", "mild", "milky", "mindless", "miniature", "minor", "minty", "miserable", "miserly", "misguided", "misty", "mixed", "modern", "modest", "moist", "monstrous", "monthly", "monumental", "moral", "mortified", "motherly", "motionless", "mountainous", "muddy", "muffled", "multicolored", "mundane", "murky", "mushy", "musty", "muted", "mysterious", "naive", "narrow", "nasty", "natural", "naughty", "nautical", "near", "neat", "necessary", "needy", "negative", "neglected", "negligible", "neighboring", "nervous", "new", "next", "nice", "nifty", "nimble", "nippy", "nocturnal", "noisy", "nonstop", "normal", "notable", "noted", "noteworthy", "novel", "noxious", "numb", "nutritious", "nutty", "obedient", "obese", "oblong", "oily", "oblong", "obvious", "occasional", "odd", "oddball", "offbeat", "offensive", "official", "old", "old-fashioned", "only", "open", "optimal", "optimistic", "opulent", "orange", "orderly", "organic", "ornate", "ornery", "ordinary", "original", "other", "our", "outlying", "outgoing", "outlandish", "outrageous", "outstanding", "oval", "overcooked", "overdue", "overjoyed", "overlooked", "palatable", "pale", "paltry", "parallel", "parched", "partial", "passionate", "past", "pastel", "peaceful", "peppery", "perfect", "perfumed", "periodic", "perky", "personal", "pertinent", "pesky", "pessimistic", "petty", "phony", "physical", "piercing", "pink", "pitiful", "plain", "plaintive", "plastic", "playful", "pleasant", "pleased", "pleasing", "plump", "plush", "polished", "polite", "political", "pointed", "pointless", "poised", "poor", "popular", "portly", "posh", "positive", "possible", "potable", "powerful", "powerless", "practical", "precious", "present", "prestigious", "pretty", "precious", "previous", "pricey", "prickly", "primary", "prime", "pristine", "private", "prize", "probable", "productive", "profitable", "profuse", "proper", "proud", "prudent", "punctual", "pungent", "puny", "pure", "purple", "pushy", "putrid", "puzzled", "puzzling", "quaint", "qualified", "quarrelsome", "quarterly", "queasy", "querulous", "questionable", "quick", "quick-witted", "quiet", "quintessential", "quirky", "quixotic", "quizzical", "radiant", "ragged", "rapid", "rare", "rash", "raw", "recent", "reckless", "rectangular", "ready", "real", "realistic", "reasonable", "red", "reflecting", "regal", "regular", "reliable", "relieved", "remarkable", "remorseful", "remote", "repentant", "required", "respectful", "responsible", "repulsive", "revolving", "rewarding", "rich", "rigid", "right", "ringed", "ripe", "roasted", "robust", "rosy", "rotating", "rotten", "rough", "round", "rowdy", "royal", "rubbery", "rundown", "ruddy", "rude", "runny", "rural", "rusty", "sad", "safe", "salty", "same", "sandy", "sane", "sarcastic", "sardonic", "satisfied", "scaly", "scarce", "scared", "scary", "scented", "scholarly", "scientific", "scornful", "scratchy", "scrawny", "second", "secondary", "second-hand", "secret", "self-assured", "self-reliant", "selfish", "sentimental", "separate", "serene", "serious", "serpentine", "several", "severe", "shabby", "shadowy", "shady", "shallow", "shameful", "shameless", "sharp", "shimmering", "shiny", "shocked", "shocking", "shoddy", "short", "short-term", "showy", "shrill", "shy", "sick", "silent", "silky", "silly", "silver", "similar", "simple", "simplistic", "sinful", "single", "sizzling", "skeletal", "skinny", "sleepy", "slight", "slim", "slimy", "slippery", "slow", "slushy", "small", "smart", "smoggy", "smooth", "smug", "snappy", "snarling", "sneaky", "sniveling", "snoopy", "sociable", "soft", "soggy", "solid", "somber", "some", "spherical", "sophisticated", "sore", "sorrowful", "soulful", "soupy", "sour", "Spanish", "sparkling", "sparse", "specific", "spectacular", "speedy", "spicy", "spiffy", "spirited", "spiteful", "splendid", "spotless", "spotted", "spry", "square", "squeaky", "squiggly", "stable", "staid", "stained", "stale", "standard", "starchy", "stark", "starry", "steep", "sticky", "stiff", "stimulating", "stingy", "stormy", "straight", "strange", "steel", "strict", "strident", "striking", "striped", "strong", "studious", "stunning", "stupendous", "stupid", "sturdy", "stylish", "subdued", "submissive", "substantial", "subtle", "suburban", "sudden", "sugary", "sunny", "super", "superb", "superficial", "superior", "supportive", "sure-footed", "surprised", "suspicious", "svelte", "sweaty", "sweet", "sweltering", "swift", "sympathetic", "tall", "talkative", "tame", "tan", "tangible", "tart", "tasty", "tattered", "taut", "tedious", "teeming", "tempting", "tender", "tense", "tepid", "terrible", "terrific", "testy", "thankful", "that", "these", "thick", "thin", "third", "thirsty", "this", "thorough", "thorny", "those", "thoughtful", "threadbare", "thrifty", "thunderous", "tidy", "tight", "timely", "tinted", "tiny", "tired", "torn", "total", "tough", "traumatic", "treasured", "tremendous", "tragic", "trained", "tremendous", "triangular", "tricky", "trifling", "trim", "trivial", "troubled", "true", "trusting", "trustworthy", "trusty", "truthful", "tubby", "turbulent", "twin", "ugly", "ultimate", "unacceptable", "unaware", "uncomfortable", "uncommon", "unconscious", "understated", "unequaled", "uneven", "unfinished", "unfit", "unfolded", "unfortunate", "unhappy", "unhealthy", "uniform", "unimportant", "unique", "united", "unkempt", "unknown", "unlawful", "unlined", "unlucky", "unnatural", "unpleasant", "unrealistic", "unripe", "unruly", "unselfish", "unsightly", "unsteady", "unsung", "untidy", "untimely", "untried", "untrue", "unused", "unusual", "unwelcome", "unwieldy", "unwilling", "unwitting", "unwritten", "upbeat", "upright", "upset", "urban", "usable", "used", "useful", "useless", "utilized", "utter", "vacant", "vague", "vain", "valid", "valuable", "vapid", "variable", "vast", "velvety", "venerated", "vengeful", "verifiable", "vibrant", "vicious", "victorious", "vigilant", "vigorous", "villainous", "violet", "violent", "virtual", "virtuous", "visible", "vital", "vivacious", "vivid", "voluminous", "wan", "warlike", "warm", "warmhearted", "warped", "wary", "wasteful", "watchful", "waterlogged", "watery", "wavy", "wealthy", "weak", "weary", "webbed", "wee", "weekly", "weepy", "weighty", "weird", "welcome", "well-documented", "well-groomed", "well-informed", "well-lit", "well-made", "well-off", "well-to-do", "well-worn", "wet", "which", "whimsical", "whirlwind", "whispered", "white", "whole", "whopping", "wicked", "wide", "wide-eyed", "wiggly", "wild", "willing", "wilted", "winding", "windy", "winged", "wiry", "wise", "witty", "wobbly", "woeful", "wonderful", "wooden", "woozy", "wordy", "worldly", "worn", "worried", "worrisome", "worse", "worst", "worthless", "worthwhile", "worthy", "wrathful", "wretched", "writhing", "wrong", "wry", "yawning", "yearly", "yellow", "yellowish", "young", "youthful", "yummy", "zany", "zealous", "zesty", "zigzag" ], "animals": [ "Cape Fox", "Short-Beaked Echidna", "Platypus", "Arctic Ground Squirrel", "Black-Tailed Prairie Dog", "Franklin's Ground Squirrel", "Golden-Mantled Ground Squirrel", "Groundhog", "Yellow-Bellied Marmot", "Eastern Mole", "Pink Fairy Armadillo", "Star-Nosed Mole", "Smooth-Coated Otter", "Degu", "Meadow Vole", "Campbell's Dwarf Hamster", "Fat Sand Rat", "Striped Ground Squirrel", "Syrian Hamster", "Common Wombat", "Greater Bilby", "Marsupial Mole", "Numbat", "Southern Hairy-Nosed Wombat", "American Badger", "Little Blue Penguin", "Giant Armadillo", "Eastern Long-Beaked Echidna", "Screaming Hairy Armadillo", "Chinese Hamster", "Roborovski Hamster", "Djungarian Hamster", "Indian Desert Jird", "Great Gerbil", "Plains Rat", "Big-Headed Mole-Rat", "Cape Ground Squirrel", "Colorado Chipmunk", "Alpine Chipmunk", "Cliff Chipmunk", "Hoary Marmot", "Himalayan Marmot", "Olympic Marmot", "San Joaquin Antelope Squirrel", "Gunnison's Prairie Dog", "California Ground Squirrel", "White-Tailed Prairie Dog", "Spotted Ground Squirrel", "Uinta Ground Squirrel", "Columbian Ground Squirrel", "Richardson's Ground Squirrel", "European Ground Squirrel", "Speckled Ground Squirrel", "Broad-Footed Mole", "European Mole", "Sunda Pangolin", "Desert Rosy Boa", "Desert Tortoise", "Brahminy Blind Snake", "Eastern Hognose Snake", "Saharan Horned Viper", "Gopher Snake", "Scarlet Kingsnake", "Eastern Pine Snake", "Eastern Coral Snake", "Naked Mole-Rat", "Mud Snake", "Barbados Threadsnake", "Arabian Sand Boa", "Japanese Badger", "Rainbow Snake", "Red-Eyed Crocodile Skink", "Texas Coral Snake", "Glossy Snake", "Oriental Wolf Snake", "Hog Badger", "Mongolian Gerbil", "Damaraland Mole-Rat", "Steppe Polecat", "Woma Python", "Southern Hognose Snake", "Asian Badger", "Giant Girdled Lizard", "Common Vole", "Bank Vole", "Chinese Ferret-Badger", "Desert Grassland Whiptail Lizard", "Rough Earth Snake", "Thirteen-Lined Ground Squirrel", "Southern Three-Banded Armadillo", "Slowworm", "Siberian Chipmunk", "Round-Tailed Ground Squirrel", "Pygmy Rabbit", "Pied Kingfisher", "Northern Short-Tailed Shrew ", "Northern Pika", "Nine-Banded Armadillo", "Nile Monitor", "Lowland Streaked Tenrec", "Lowland Paca", "Long-Nosed Bandicoot", "Long-Eared Jerboa", "Idaho Ground Squirrel", "Ground Pangolin", "Great Plains Rat Snake", "Gopher Tortoise", "Giant Pangolin", "European Hedgehog", "European Hamster", "Common Box Turtle", "Brown Rat", "Bog Turtle", "Bengal Fox", "American Alligator", "Aardvark", "Olm", "Tiger salamander", "Chinese giant salamander", "Spotted salamander", "Blue-spotted salamander", "Eastern worm snake", "Deinagkistrodon", "Northern crested newt", "Barred tiger salamander", "Rainbow bee-eater", "Sunbeam Snake", "Sandfish Skink", "Mexican Mole Lizard", "Tarbagan marmot", "Black-Headed Python", "Vancouver Island Marmot", "Bothrochilus", "Western Box Turtle", "Long-toed salamander", "Fat-Tailed Gerbil", "Mexican Prairie Dog", "Marbled salamander", "Bandy-Bandy", "Smooth Earth Snake", "Boodie", "Zebra-Tailed Lizard", "White-headed langur", "Javan Ferret-Badger", "Southwestern Blackhead Snake", "Malagasy Giant Rat", "Big Hairy Armadillo", "Camas pocket gopher", "Woodland vole", "Lesser Egyptian jerboa", "Little Brown Skink", "Plains pocket gopher", "Alaska marmot", "Gray marmot", "Louisiana waterthrush", "Ord's kangaroo rat", "North American least shrew", "Western rosella", "Northwestern salamander", "Acrochordus granulatus", "Kowari", "Anilius", "Gastrophryne carolinensis", "Yellow mud turtle", "Plateau pika", "Steppe lemming", "American shrew mole", "Calabar python", "Dermophis mexicanus", "Rufous rat-kangaroo", "Hairy-tailed mole", "Mexican burrowing toad", "Seven-banded armadillo", "Scaphiopus holbrookii", "Asiatic brush-tailed porcupine", "Bolson tortoise", "Common midwife toad", "Ambystoma talpoideum", "Crucifix toad", "Red Hills salamander", "Uperodon taprobanicus", "Plains spadefoot toad", "Spea hammondii", "Puerto Rican crested toad", "Physalaemus nattereri", "Yosemite toad", "Frosted flatwoods salamander", "Striped newt", "Streamside salamander", "Southern red-backed salamander", "Spencer's burrowing frog", "Ringed salamander", "Kaloula baleata", "Uperodon systoma", "Ichthyophis beddomei", "Uperodon globulosus", "Herpele squalostoma", "Ichthyophis mindanaoensis", "Sandhill frog", "Strecker's chorus frog", "Uraeotyphlus oxyurus", "Caecilia nigricans", "Uraeotyphlus menoni", "Savannah forest tree frog", "Uraeotyphlus interruptus", "Rose's rain frog", "Dermophis parviceps", "Leptopelis gramineus", "Rhombophryne coudreaui", "Elachistocleis pearsei", "Hylodes heyeri", "Carphophis vermis", "Anniella pulchra", "Lampropeltis calligaster rhombomaculata", "Xerotyphlops vermicularis", "Iberian worm lizard", "Lytorhynchus diadema", "Micrurus frontalis", "Euprepiophis conspicillata", "Amphisbaena fuliginosa", "Greater earless lizard", "Afrotyphlops schlegelii", "Texas lined snake", "Atractaspis branchi", "Calamaria gervaisii", "Brachyurophis fasciolatus", "Brongersma's worm snake", "Letheobia simonii", "Grypotyphlops acutus", "Acontias breviceps", "Reticulate worm snake", "Trinidad worm snake", "Amphisbaena microcephala", "Lerista labialis", "Flathead worm snake", "Mertens's worm lizard", "Elegant worm snake", "Iranian worm snake", "Pernambuco worm snake", "Crest-tailed mulgara", "Southern long-nosed armadillo", "Greater fairy armadillo", "Steppe pika", "Black-capped marmot", "Armored rat", "Giant mole-rat", "Montane vole", "Oldfield mouse", "Southeastern pocket gopher", "Long-tailed vole", "Greater naked-tailed armadillo", "Common mole-rat", "Philippine porcupine", "Milne-Edwards's sifaka", "Townsend's mole", "Giant golden mole", "Daurian pika", "Cape golden mole", "Yellow-faced pocket gopher", "Indian gerbil", "Plains viscacha rat", "Red tree vole", "Middle East blind mole-rat", "Mountain paca", "Pallas's pika", "Bicolored shrew", "Cape mole-rat", "Cascade golden-mantled ground squirrel", "Unstriped ground squirrel", "Townsend's vole", "Yellow ground squirrel", "Desert pocket gopher", "Bunny rat", "Washington ground squirrel", "Mole-like rice tenrec", "Greater mole-rat", "Hottentot golden mole", "Plains pocket mouse", "Cheesman's gerbil", "Judean Mountains blind mole-rat", "Chisel-toothed kangaroo rat", "Rough-haired golden mole", "Southeastern shrew", "California pocket mouse", "Coruro", "Merriam's shrew", "Long-tailed mole", "Orange leaf-nosed bat", "South African pouched mouse", "Selous's mongoose", "Ash-grey mouse", "Russet ground squirrel", "Gulf Coast kangaroo rat", "Olive-backed pocket mouse", "Northeast African mole-rat", "San Diego pocket mouse", "Nelson's pocket mouse", "Geoffroy's horseshoe bat", "Narrow-faced kangaroo rat", "Chilean rock rat", "R\u00fcppell's horseshoe bat", "Long-tailed pocket mouse", "Aztec mouse", "Western mouse", "Felten's myotis", "Akodon azarae", "Talas tuco-tuco", "Upper Galilee Mountains blind mole-rat", "Pearson's tuco-tuco", "Mount Carmel blind mole-rat", "Plethobasus cyphyus", "Long-Nosed Snake", "Russian Desman", "Texas Blind Snake", "Florida Box Turtle", "Lesser Bandicoot Rat", "Bush Rat", "Six-Lined Racerunner", "Eastern Bearded Dragon", "Lesser Antillean Iguana", "Eastern Mud Turtle", "Slender Glass Lizard", "Scarlet Snake", "Natal Multimammate Mouse", "Mountain Beaver", "Bobak Marmot", "Kirtland's Snake", "Pine Woods Snake", "Western Whiptail", "Boxelder bug", "Porcellio scaber", "German cockroach", "Forficula auricularia", "Anisolabis maritima", "Trigoniulus corallinus", "Sinea diadema", "Black imported fire ant", "Scutigera coleoptrata", "Mastigoproctus giganteus", "Dermacentor andersoni", "Deathstalker", "Larinioides cornutus", "Cheiracanthium inclusum", "Latrodectus hesperus", "Scytodes thoracica", "Atypus affinis", "Illacme plenipes", "Ommatoiulus moreleti", "Narceus americanus", "Madagascar hissing cockroach", "Labidura riparia", "Forficula smyrnensis", "Argentine ant", "Texas leafcutter ant", "Brachypelma klaasi", "Western Blind Snake", "Desert Box Turtle", "African Striped Weasel" ] } ================================================ FILE: server/db/names.ts ================================================ import { join } from "path"; import { readFileSync } from "fs"; import { clients, db, resources, siteResources } from "@server/db"; import { randomInt } from "crypto"; import { exitNodes, sites } from "@server/db"; import { eq, and } from "drizzle-orm"; import { __DIRNAME } from "@server/lib/consts"; // Load the names from the names.json file const dev = process.env.ENVIRONMENT !== "prod"; let file; if (!dev) { file = join(__DIRNAME, "names.json"); } else { file = join("server/db/names.json"); } export const names = JSON.parse(readFileSync(file, "utf-8")); // Load iOS and Mac model mappings let iosModelsFile: string; let macModelsFile: string; if (!dev) { iosModelsFile = join(__DIRNAME, "ios_models.json"); macModelsFile = join(__DIRNAME, "mac_models.json"); } else { iosModelsFile = join("server/db/ios_models.json"); macModelsFile = join("server/db/mac_models.json"); } const iosModels: Record = JSON.parse( readFileSync(iosModelsFile, "utf-8") ); const macModels: Record = JSON.parse( readFileSync(macModelsFile, "utf-8") ); export async function getUniqueClientName(orgId: string): Promise { let loops = 0; while (true) { if (loops > 100) { throw new Error("Could not generate a unique name"); } const name = generateName(); const count = await db .select({ niceId: clients.niceId, orgId: clients.orgId }) .from(clients) .where(and(eq(clients.niceId, name), eq(clients.orgId, orgId))); if (count.length === 0) { return name; } loops++; } } export async function getUniqueSiteName(orgId: string): Promise { let loops = 0; while (true) { if (loops > 100) { throw new Error("Could not generate a unique name"); } const name = generateName(); const count = await db .select({ niceId: sites.niceId, orgId: sites.orgId }) .from(sites) .where(and(eq(sites.niceId, name), eq(sites.orgId, orgId))); if (count.length === 0) { return name; } loops++; } } export async function getUniqueResourceName(orgId: string): Promise { let loops = 0; while (true) { if (loops > 100) { throw new Error("Could not generate a unique name"); } const name = generateName(); const [resourceCount, siteResourceCount] = await Promise.all([ db .select({ niceId: resources.niceId, orgId: resources.orgId }) .from(resources) .where( and(eq(resources.niceId, name), eq(resources.orgId, orgId)) ), db .select({ niceId: siteResources.niceId, orgId: siteResources.orgId }) .from(siteResources) .where( and( eq(siteResources.niceId, name), eq(siteResources.orgId, orgId) ) ) ]); if (resourceCount.length === 0 && siteResourceCount.length === 0) { return name; } loops++; } } export async function getUniqueSiteResourceName( orgId: string ): Promise { let loops = 0; while (true) { if (loops > 100) { throw new Error("Could not generate a unique name"); } const name = generateName(); const [resourceCount, siteResourceCount] = await Promise.all([ db .select({ niceId: resources.niceId, orgId: resources.orgId }) .from(resources) .where( and(eq(resources.niceId, name), eq(resources.orgId, orgId)) ), db .select({ niceId: siteResources.niceId, orgId: siteResources.orgId }) .from(siteResources) .where( and( eq(siteResources.niceId, name), eq(siteResources.orgId, orgId) ) ) ]); if (resourceCount.length === 0 && siteResourceCount.length === 0) { return name; } loops++; } } export async function getUniqueExitNodeEndpointName(): Promise { let loops = 0; const count = await db.select().from(exitNodes); while (true) { if (loops > 100) { throw new Error("Could not generate a unique name"); } const name = generateName(); for (const node of count) { if (node.endpoint.includes(name)) { loops++; continue; } } return name; } } export function generateName(): string { const name = ( names.descriptors[randomInt(names.descriptors.length)] + "-" + names.animals[randomInt(names.animals.length)] ) .toLowerCase() .replace(/\s/g, "-"); // clean out any non-alphanumeric characters except for dashes return name.replace(/[^a-z0-9-]/g, ""); } export function getMacDeviceName(macIdentifier?: string | null): string | null { if (macIdentifier && macModels[macIdentifier]) { return macModels[macIdentifier]; } return null; } export function getIosDeviceName(iosIdentifier?: string | null): string | null { if (iosIdentifier && iosModels[iosIdentifier]) { return iosModels[iosIdentifier]; } return null; } export function getUserDeviceName( model: string | null, fallBack: string | null ): string { return ( getMacDeviceName(model) || getIosDeviceName(model) || fallBack || "Unknown Device" ); } ================================================ FILE: server/db/pg/driver.ts ================================================ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { readConfigFile } from "@server/lib/readConfigFile"; import { withReplicas } from "drizzle-orm/pg-core"; function createDb() { const config = readConfigFile(); // check the environment variables for postgres config first before the config file if (process.env.POSTGRES_CONNECTION_STRING) { config.postgres = { connection_string: process.env.POSTGRES_CONNECTION_STRING }; if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) { const replicas = process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map( (conn) => ({ connection_string: conn.trim() }) ); config.postgres.replicas = replicas; } } if (!config.postgres) { throw new Error( "Postgres configuration is missing in the configuration file." ); } const connectionString = config.postgres?.connection_string; const replicaConnections = config.postgres?.replicas || []; if (!connectionString) { throw new Error( "A primary db connection string is required in the configuration file." ); } // Create connection pools instead of individual connections const poolConfig = config.postgres.pool; const primaryPool = new Pool({ connectionString, max: poolConfig?.max_connections || 20, idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000, connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000 }); const replicas = []; if (!replicaConnections.length) { replicas.push( DrizzlePostgres(primaryPool, { logger: process.env.QUERY_LOGGING == "true" }) ); } else { for (const conn of replicaConnections) { const replicaPool = new Pool({ connectionString: conn.connection_string, max: poolConfig?.max_replica_connections || 20, idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000, connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000 }); replicas.push( DrizzlePostgres(replicaPool, { logger: process.env.QUERY_LOGGING == "true" }) ); } } return withReplicas( DrizzlePostgres(primaryPool, { logger: process.env.QUERY_LOGGING == "true" }), replicas as any ); } export const db = createDb(); export default db; export const primaryDb = db.$primary; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] >[0]; ================================================ FILE: server/db/pg/index.ts ================================================ export * from "./driver"; export * from "./logsDriver"; export * from "./safeRead"; export * from "./schema/schema"; export * from "./schema/privateSchema"; export * from "./migrate"; ================================================ FILE: server/db/pg/logsDriver.ts ================================================ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; import { readConfigFile } from "@server/lib/readConfigFile"; import { withReplicas } from "drizzle-orm/pg-core"; import { build } from "@server/build"; import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver"; function createLogsDb() { // Only use separate logs database in SaaS builds if (build !== "saas") { return mainDb; } const config = readConfigFile(); // Merge configs, prioritizing private config const logsConfig = config.postgres_logs; // Check environment variable first let connectionString = process.env.POSTGRES_LOGS_CONNECTION_STRING; let replicaConnections: Array<{ connection_string: string }> = []; if (!connectionString && logsConfig) { connectionString = logsConfig.connection_string; replicaConnections = logsConfig.replicas || []; } // If POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS is set, use it if (process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS) { replicaConnections = process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS.split(",").map( (conn) => ({ connection_string: conn.trim() }) ); } // If no logs database is configured, fall back to main database if (!connectionString) { return mainDb; } // Create separate connection pool for logs database const poolConfig = logsConfig?.pool || config.postgres?.pool; const primaryPool = new Pool({ connectionString, max: poolConfig?.max_connections || 20, idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000, connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000 }); const replicas = []; if (!replicaConnections.length) { replicas.push( DrizzlePostgres(primaryPool, { logger: process.env.QUERY_LOGGING == "true" }) ); } else { for (const conn of replicaConnections) { const replicaPool = new Pool({ connectionString: conn.connection_string, max: poolConfig?.max_replica_connections || 20, idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000, connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000 }); replicas.push( DrizzlePostgres(replicaPool, { logger: process.env.QUERY_LOGGING == "true" }) ); } } return withReplicas( DrizzlePostgres(primaryPool, { logger: process.env.QUERY_LOGGING == "true" }), replicas as any ); } export const logsDb = createLogsDb(); export default logsDb; export const primaryLogsDb = logsDb.$primary; ================================================ FILE: server/db/pg/migrate.ts ================================================ import { migrate } from "drizzle-orm/node-postgres/migrator"; import { db } from "./driver"; import path from "path"; const migrationsFolder = path.join("server/migrations"); export const runMigrations = async () => { console.log("Running migrations..."); try { await migrate(db as any, { migrationsFolder: migrationsFolder }); console.log("Migrations completed successfully. ✅"); process.exit(0); } catch (error) { console.error("Error running migrations:", error); process.exit(1); } }; ================================================ FILE: server/db/pg/safeRead.ts ================================================ import { db, primaryDb } from "./driver"; /** * Runs a read query with replica fallback for Postgres. * Executes the query against the replica first (when replicas exist). * If the query throws or returns no data (null, undefined, or empty array), * runs the same query against the primary. */ export async function safeRead( query: (d: typeof db | typeof primaryDb) => Promise ): Promise { try { const result = await query(db); if (result === undefined || result === null) { return query(primaryDb); } if (Array.isArray(result) && result.length === 0) { return query(primaryDb); } return result; } catch { return query(primaryDb); } } ================================================ FILE: server/db/pg/schema/privateSchema.ts ================================================ import { pgTable, serial, varchar, boolean, integer, bigint, real, text, index } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; import { domains, orgs, targets, users, exitNodes, sessions, clients } from "./schema"; export const certificates = pgTable("certificates", { certId: serial("certId").primaryKey(), domain: varchar("domain", { length: 255 }).notNull().unique(), domainId: varchar("domainId").references(() => domains.domainId, { onDelete: "cascade" }), wildcard: boolean("wildcard").default(false), status: varchar("status", { length: 50 }).notNull().default("pending"), // pending, requested, valid, expired, failed expiresAt: bigint("expiresAt", { mode: "number" }), lastRenewalAttempt: bigint("lastRenewalAttempt", { mode: "number" }), createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }).notNull(), orderId: varchar("orderId", { length: 500 }), errorMessage: text("errorMessage"), renewalCount: integer("renewalCount").default(0), certFile: text("certFile"), keyFile: text("keyFile") }); export const dnsChallenge = pgTable("dnsChallenges", { dnsChallengeId: serial("dnsChallengeId").primaryKey(), domain: varchar("domain", { length: 255 }).notNull(), token: varchar("token", { length: 255 }).notNull(), keyAuthorization: varchar("keyAuthorization", { length: 1000 }).notNull(), createdAt: bigint("createdAt", { mode: "number" }).notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), completed: boolean("completed").default(false) }); export const account = pgTable("account", { accountId: serial("accountId").primaryKey(), userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }) }); export const customers = pgTable("customers", { customerId: varchar("customerId", { length: 255 }).primaryKey().notNull(), orgId: varchar("orgId", { length: 255 }) .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), // accountId: integer("accountId") // .references(() => account.accountId, { onDelete: "cascade" }), // Optional, if using accounts email: varchar("email", { length: 255 }), name: varchar("name", { length: 255 }), phone: varchar("phone", { length: 50 }), address: text("address"), createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }).notNull() }); export const subscriptions = pgTable("subscriptions", { subscriptionId: varchar("subscriptionId", { length: 255 }) .primaryKey() .notNull(), customerId: varchar("customerId", { length: 255 }) .notNull() .references(() => customers.customerId, { onDelete: "cascade" }), status: varchar("status", { length: 50 }).notNull().default("active"), // active, past_due, canceled, unpaid canceledAt: bigint("canceledAt", { mode: "number" }), createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }), version: integer("version"), billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license }); export const subscriptionItems = pgTable("subscriptionItems", { subscriptionItemId: serial("subscriptionItemId").primaryKey(), stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { length: 255 }), subscriptionId: varchar("subscriptionId", { length: 255 }) .notNull() .references(() => subscriptions.subscriptionId, { onDelete: "cascade" }), planId: varchar("planId", { length: 255 }).notNull(), priceId: varchar("priceId", { length: 255 }), featureId: varchar("featureId", { length: 255 }), meterId: varchar("meterId", { length: 255 }), unitAmount: real("unitAmount"), tiers: text("tiers"), interval: varchar("interval", { length: 50 }), currentPeriodStart: bigint("currentPeriodStart", { mode: "number" }), currentPeriodEnd: bigint("currentPeriodEnd", { mode: "number" }), name: varchar("name", { length: 255 }) }); export const accountDomains = pgTable("accountDomains", { accountId: integer("accountId") .notNull() .references(() => account.accountId, { onDelete: "cascade" }), domainId: varchar("domainId") .notNull() .references(() => domains.domainId, { onDelete: "cascade" }) }); export const usage = pgTable("usage", { usageId: varchar("usageId", { length: 255 }).primaryKey(), featureId: varchar("featureId", { length: 255 }).notNull(), orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), meterId: varchar("meterId", { length: 255 }), instantaneousValue: real("instantaneousValue"), latestValue: real("latestValue").notNull(), previousValue: real("previousValue"), updatedAt: bigint("updatedAt", { mode: "number" }).notNull(), rolledOverAt: bigint("rolledOverAt", { mode: "number" }), nextRolloverAt: bigint("nextRolloverAt", { mode: "number" }) }); export const limits = pgTable("limits", { limitId: varchar("limitId", { length: 255 }).primaryKey(), featureId: varchar("featureId", { length: 255 }).notNull(), orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), value: real("value"), override: boolean("override").default(false), description: text("description") }); export const usageNotifications = pgTable("usageNotifications", { notificationId: serial("notificationId").primaryKey(), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), featureId: varchar("featureId", { length: 255 }).notNull(), limitId: varchar("limitId", { length: 255 }).notNull(), notificationType: varchar("notificationType", { length: 50 }).notNull(), sentAt: bigint("sentAt", { mode: "number" }).notNull() }); export const domainNamespaces = pgTable("domainNamespaces", { domainNamespaceId: varchar("domainNamespaceId", { length: 255 }).primaryKey(), domainId: varchar("domainId") .references(() => domains.domainId, { onDelete: "set null" }) .notNull() }); export const exitNodeOrgs = pgTable("exitNodeOrgs", { exitNodeId: integer("exitNodeId") .notNull() .references(() => exitNodes.exitNodeId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const remoteExitNodes = pgTable("remoteExitNode", { remoteExitNodeId: varchar("id").primaryKey(), secretHash: varchar("secretHash").notNull(), dateCreated: varchar("dateCreated").notNull(), version: varchar("version"), secondaryVersion: varchar("secondaryVersion"), // This is to detect the new nodes after the transition to pangolin-node exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { onDelete: "cascade" }) }); export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", { sessionId: varchar("id").primaryKey(), remoteExitNodeId: varchar("remoteExitNodeId") .notNull() .references(() => remoteExitNodes.remoteExitNodeId, { onDelete: "cascade" }), expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); export const loginPage = pgTable("loginPage", { loginPageId: serial("loginPageId").primaryKey(), subdomain: varchar("subdomain"), fullDomain: varchar("fullDomain"), exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), domainId: varchar("domainId").references(() => domains.domainId, { onDelete: "set null" }) }); export const loginPageOrg = pgTable("loginPageOrg", { loginPageId: integer("loginPageId") .notNull() .references(() => loginPage.loginPageId, { onDelete: "cascade" }), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const loginPageBranding = pgTable("loginPageBranding", { loginPageBrandingId: serial("loginPageBrandingId").primaryKey(), logoUrl: text("logoUrl"), logoWidth: integer("logoWidth").notNull(), logoHeight: integer("logoHeight").notNull(), primaryColor: text("primaryColor"), resourceTitle: text("resourceTitle").notNull(), resourceSubtitle: text("resourceSubtitle"), orgTitle: text("orgTitle"), orgSubtitle: text("orgSubtitle") }); export const loginPageBrandingOrg = pgTable("loginPageBrandingOrg", { loginPageBrandingId: integer("loginPageBrandingId") .notNull() .references(() => loginPageBranding.loginPageBrandingId, { onDelete: "cascade" }), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const sessionTransferToken = pgTable("sessionTransferToken", { token: varchar("token").primaryKey(), sessionId: varchar("sessionId") .notNull() .references(() => sessions.sessionId, { onDelete: "cascade" }), encryptedSession: text("encryptedSession").notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); export const actionAuditLog = pgTable( "actionAuditLog", { id: serial("id").primaryKey(), timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), actorType: varchar("actorType", { length: 50 }).notNull(), actor: varchar("actor", { length: 255 }).notNull(), actorId: varchar("actorId", { length: 255 }).notNull(), action: varchar("action", { length: 100 }).notNull(), metadata: text("metadata") }, (table) => [ index("idx_actionAuditLog_timestamp").on(table.timestamp), index("idx_actionAuditLog_org_timestamp").on( table.orgId, table.timestamp ) ] ); export const accessAuditLog = pgTable( "accessAuditLog", { id: serial("id").primaryKey(), timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), actorType: varchar("actorType", { length: 50 }), actor: varchar("actor", { length: 255 }), actorId: varchar("actorId", { length: 255 }), resourceId: integer("resourceId"), ip: varchar("ip", { length: 45 }), type: varchar("type", { length: 100 }).notNull(), action: boolean("action").notNull(), location: text("location"), userAgent: text("userAgent"), metadata: text("metadata") }, (table) => [ index("idx_identityAuditLog_timestamp").on(table.timestamp), index("idx_identityAuditLog_org_timestamp").on( table.orgId, table.timestamp ) ] ); export const approvals = pgTable("approvals", { approvalId: serial("approvalId").primaryKey(), timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), clientId: integer("clientId").references(() => clients.clientId, { onDelete: "cascade" }), // clients reference user devices (in this case) userId: varchar("userId") .references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" }) .notNull(), decision: varchar("decision") .$type<"approved" | "denied" | "pending">() .default("pending") .notNull(), type: varchar("type") .$type<"user_device" /*| 'proxy' // for later */>() .notNull() }); export const bannedEmails = pgTable("bannedEmails", { email: varchar("email", { length: 255 }).primaryKey(), }); export const bannedIps = pgTable("bannedIps", { ip: varchar("ip", { length: 255 }).primaryKey(), }); export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; export type DnsChallenge = InferSelectModel; export type Customer = InferSelectModel; export type Subscription = InferSelectModel; export type SubscriptionItem = InferSelectModel; export type Usage = InferSelectModel; export type UsageLimit = InferSelectModel; export type AccountDomain = InferSelectModel; export type UsageNotification = InferSelectModel; export type RemoteExitNode = InferSelectModel; export type RemoteExitNodeSession = InferSelectModel< typeof remoteExitNodeSessions >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; export type LoginPageBranding = InferSelectModel; export type ActionAuditLog = InferSelectModel; export type AccessAuditLog = InferSelectModel; ================================================ FILE: server/db/pg/schema/schema.ts ================================================ import { randomUUID } from "crypto"; 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(), baseDomain: varchar("baseDomain").notNull(), configManaged: boolean("configManaged").notNull().default(false), type: varchar("type"), // "ns", "cname", "wildcard" verified: boolean("verified").notNull().default(false), failed: boolean("failed").notNull().default(false), tries: integer("tries").notNull().default(0), certResolver: varchar("certResolver"), customCertResolver: varchar("customCertResolver"), preferWildcardCert: boolean("preferWildcardCert"), errorMessage: text("errorMessage") }); export const dnsRecords = pgTable("dnsRecords", { id: serial("id").primaryKey(), domainId: varchar("domainId") .notNull() .references(() => domains.domainId, { onDelete: "cascade" }), recordType: varchar("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" baseDomain: varchar("baseDomain"), value: varchar("value").notNull(), verified: boolean("verified").notNull().default(false) }); export const orgs = pgTable("orgs", { orgId: varchar("orgId").primaryKey(), name: varchar("name").notNull(), subnet: varchar("subnet"), utilitySubnet: varchar("utilitySubnet"), // this is the subnet for utility addresses createdAt: text("createdAt"), requireTwoFactor: boolean("requireTwoFactor"), maxSessionLengthHours: integer("maxSessionLengthHours"), passwordExpiryDays: integer("passwordExpiryDays"), settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever, and 9001 = end of the following year .notNull() .default(7), settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .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", { orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), domainId: varchar("domainId") .notNull() .references(() => domains.domainId, { onDelete: "cascade" }) }); export const sites = pgTable("sites", { siteId: serial("siteId").primaryKey(), orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), niceId: varchar("niceId").notNull(), exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), name: varchar("name").notNull(), pubKey: varchar("pubKey"), subnet: varchar("subnet"), megabytesIn: real("bytesIn").default(0), megabytesOut: real("bytesOut").default(0), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), type: varchar("type").notNull(), // "newt" or "wireguard" online: boolean("online").notNull().default(false), lastPing: integer("lastPing"), address: varchar("address"), endpoint: varchar("endpoint"), publicKey: varchar("publicKey"), lastHolePunch: bigint("lastHolePunch", { mode: "number" }), listenPort: integer("listenPort"), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true) }); export const resources = pgTable("resources", { resourceId: serial("resourceId").primaryKey(), resourceGuid: varchar("resourceGuid", { length: 36 }) .unique() .notNull() .$defaultFn(() => randomUUID()), orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), niceId: text("niceId").notNull(), name: varchar("name").notNull(), subdomain: varchar("subdomain"), fullDomain: varchar("fullDomain"), domainId: varchar("domainId").references(() => domains.domainId, { onDelete: "set null" }), ssl: boolean("ssl").notNull().default(false), blockAccess: boolean("blockAccess").notNull().default(false), sso: boolean("sso").notNull().default(true), http: boolean("http").notNull().default(true), protocol: varchar("protocol").notNull(), proxyPort: integer("proxyPort"), emailWhitelistEnabled: boolean("emailWhitelistEnabled") .notNull() .default(false), applyRules: boolean("applyRules").notNull().default(false), enabled: boolean("enabled").notNull().default(true), stickySession: boolean("stickySession").notNull().default(false), tlsServerName: varchar("tlsServerName"), setHostHeader: varchar("setHostHeader"), enableProxy: boolean("enableProxy").default(true), skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "set null" }), headers: text("headers"), // comma-separated list of headers to add to the request proxyProtocol: boolean("proxyProtocol").notNull().default(false), proxyProtocolVersion: integer("proxyProtocolVersion").default(1), maintenanceModeEnabled: boolean("maintenanceModeEnabled") .notNull() .default(false), maintenanceModeType: text("maintenanceModeType", { enum: ["forced", "automatic"] }).default("forced"), // "forced" = always show, "automatic" = only when down maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), postAuthPath: text("postAuthPath") }); export const targets = pgTable("targets", { targetId: serial("targetId").primaryKey(), resourceId: integer("resourceId") .references(() => resources.resourceId, { onDelete: "cascade" }) .notNull(), siteId: integer("siteId") .references(() => sites.siteId, { onDelete: "cascade" }) .notNull(), ip: varchar("ip").notNull(), method: varchar("method"), port: integer("port").notNull(), internalPort: integer("internalPort"), enabled: boolean("enabled").notNull().default(true), path: text("path"), pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix priority: integer("priority").notNull().default(100) }); export const targetHealthCheck = pgTable("targetHealthCheck", { targetHealthCheckId: serial("targetHealthCheckId").primaryKey(), targetId: integer("targetId") .notNull() .references(() => targets.targetId, { onDelete: "cascade" }), hcEnabled: boolean("hcEnabled").notNull().default(false), hcPath: varchar("hcPath"), hcScheme: varchar("hcScheme"), hcMode: varchar("hcMode").default("http"), hcHostname: varchar("hcHostname"), hcPort: integer("hcPort"), hcInterval: integer("hcInterval").default(30), // in seconds hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds hcTimeout: integer("hcTimeout").default(5), // in seconds hcHeaders: varchar("hcHeaders"), hcFollowRedirects: boolean("hcFollowRedirects").default(true), hcMethod: varchar("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code hcHealth: text("hcHealth") .$type<"unknown" | "healthy" | "unhealthy">() .default("unknown"), // "unknown", "healthy", "unhealthy" hcTlsServerName: text("hcTlsServerName") }); export const exitNodes = pgTable("exitNodes", { exitNodeId: serial("exitNodeId").primaryKey(), name: varchar("name").notNull(), address: varchar("address").notNull(), endpoint: varchar("endpoint").notNull(), publicKey: varchar("publicKey").notNull(), listenPort: integer("listenPort").notNull(), reachableAt: varchar("reachableAt"), maxConnections: integer("maxConnections"), online: boolean("online").notNull().default(false), lastPing: integer("lastPing"), type: text("type").default("gerbil"), // gerbil, remoteExitNode region: varchar("region") }); export const siteResources = pgTable("siteResources", { // this is for the clients siteResourceId: serial("siteResourceId").primaryKey(), siteId: integer("siteId") .notNull() .references(() => sites.siteId, { onDelete: "cascade" }), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), 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 destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode enabled: boolean("enabled").notNull().default(true), alias: varchar("alias"), aliasAddress: varchar("aliasAddress"), tcpPortRangeString: varchar("tcpPortRangeString").notNull().default("*"), udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), 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", { clientId: integer("clientId") .notNull() .references(() => clients.clientId, { onDelete: "cascade" }), siteResourceId: integer("siteResourceId") .notNull() .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) }); export const roleSiteResources = pgTable("roleSiteResources", { roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }), siteResourceId: integer("siteResourceId") .notNull() .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) }); export const userSiteResources = pgTable("userSiteResources", { userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), siteResourceId: integer("siteResourceId") .notNull() .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) }); export const users = pgTable("user", { userId: varchar("id").primaryKey(), email: varchar("email"), username: varchar("username").notNull(), name: varchar("name"), type: varchar("type").notNull(), // "internal", "oidc" idpId: integer("idpId").references(() => idp.idpId, { onDelete: "cascade" }), passwordHash: varchar("passwordHash"), twoFactorEnabled: boolean("twoFactorEnabled").notNull().default(false), twoFactorSetupRequested: boolean("twoFactorSetupRequested").default(false), twoFactorSecret: varchar("twoFactorSecret"), emailVerified: boolean("emailVerified").notNull().default(false), dateCreated: varchar("dateCreated").notNull(), termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"), termsVersion: varchar("termsVersion"), marketingEmailConsent: boolean("marketingEmailConsent").default(false), serverAdmin: boolean("serverAdmin").notNull().default(false), lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }) }); export const newts = pgTable("newt", { newtId: varchar("id").primaryKey(), secretHash: varchar("secretHash").notNull(), dateCreated: varchar("dateCreated").notNull(), version: varchar("version"), siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }) }); export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", { codeId: serial("id").primaryKey(), userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), codeHash: varchar("codeHash").notNull() }); export const sessions = pgTable("session", { sessionId: varchar("id").primaryKey(), userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), issuedAt: bigint("issuedAt", { mode: "number" }), deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false) }); export const newtSessions = pgTable("newtSession", { sessionId: varchar("id").primaryKey(), newtId: varchar("newtId") .notNull() .references(() => newts.newtId, { onDelete: "cascade" }), expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); export const userOrgs = pgTable("userOrgs", { userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), roleId: integer("roleId") .notNull() .references(() => roles.roleId), isOwner: boolean("isOwner").notNull().default(false), autoProvisioned: boolean("autoProvisioned").default(false), pamUsername: varchar("pamUsername") // cleaned username for ssh and such }); export const emailVerificationCodes = pgTable("emailVerificationCodes", { codeId: serial("id").primaryKey(), userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), email: varchar("email").notNull(), code: varchar("code").notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); export const passwordResetTokens = pgTable("passwordResetTokens", { tokenId: serial("id").primaryKey(), email: varchar("email").notNull(), userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), tokenHash: varchar("tokenHash").notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); export const actions = pgTable("actions", { actionId: varchar("actionId").primaryKey(), name: varchar("name"), description: varchar("description") }); export const roles = pgTable("roles", { roleId: serial("roleId").primaryKey(), orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), isAdmin: boolean("isAdmin"), name: varchar("name").notNull(), description: varchar("description"), 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", { roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }), actionId: varchar("actionId") .notNull() .references(() => actions.actionId, { onDelete: "cascade" }), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const userActions = pgTable("userActions", { userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), actionId: varchar("actionId") .notNull() .references(() => actions.actionId, { onDelete: "cascade" }), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const roleSites = pgTable("roleSites", { roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }), siteId: integer("siteId") .notNull() .references(() => sites.siteId, { onDelete: "cascade" }) }); export const userSites = pgTable("userSites", { userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), siteId: integer("siteId") .notNull() .references(() => sites.siteId, { onDelete: "cascade" }) }); export const roleResources = pgTable("roleResources", { roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const userResources = pgTable("userResources", { userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const userInvites = pgTable("userInvites", { inviteId: varchar("inviteId").primaryKey(), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), email: varchar("email").notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), tokenHash: varchar("token").notNull(), roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }) }); export const resourcePincode = pgTable("resourcePincode", { pincodeId: serial("pincodeId").primaryKey(), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), pincodeHash: varchar("pincodeHash").notNull(), digitLength: integer("digitLength").notNull() }); export const resourcePassword = pgTable("resourcePassword", { passwordId: serial("passwordId").primaryKey(), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), passwordHash: varchar("passwordHash").notNull() }); export const resourceHeaderAuth = pgTable("resourceHeaderAuth", { headerAuthId: serial("headerAuthId").primaryKey(), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), headerAuthHash: varchar("headerAuthHash").notNull() }); export const resourceHeaderAuthExtendedCompatibility = pgTable( "resourceHeaderAuthExtendedCompatibility", { headerAuthExtendedCompatibilityId: serial( "headerAuthExtendedCompatibilityId" ).primaryKey(), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), extendedCompatibilityIsActivated: boolean( "extendedCompatibilityIsActivated" ) .notNull() .default(true) } ); export const resourceAccessToken = pgTable("resourceAccessToken", { accessTokenId: varchar("accessTokenId").primaryKey(), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), tokenHash: varchar("tokenHash").notNull(), sessionLength: bigint("sessionLength", { mode: "number" }).notNull(), expiresAt: bigint("expiresAt", { mode: "number" }), title: varchar("title"), description: varchar("description"), createdAt: bigint("createdAt", { mode: "number" }).notNull() }); export const resourceSessions = pgTable("resourceSessions", { sessionId: varchar("id").primaryKey(), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), sessionLength: bigint("sessionLength", { mode: "number" }).notNull(), doNotExtend: boolean("doNotExtend").notNull().default(false), isRequestToken: boolean("isRequestToken"), userSessionId: varchar("userSessionId").references( () => sessions.sessionId, { onDelete: "cascade" } ), passwordId: integer("passwordId").references( () => resourcePassword.passwordId, { onDelete: "cascade" } ), pincodeId: integer("pincodeId").references( () => resourcePincode.pincodeId, { onDelete: "cascade" } ), whitelistId: integer("whitelistId").references( () => resourceWhitelist.whitelistId, { onDelete: "cascade" } ), accessTokenId: varchar("accessTokenId").references( () => resourceAccessToken.accessTokenId, { onDelete: "cascade" } ), issuedAt: bigint("issuedAt", { mode: "number" }) }); export const resourceWhitelist = pgTable("resourceWhitelist", { whitelistId: serial("id").primaryKey(), email: varchar("email").notNull(), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const resourceOtp = pgTable("resourceOtp", { otpId: serial("otpId").primaryKey(), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), email: varchar("email").notNull(), otpHash: varchar("otpHash").notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); export const versionMigrations = pgTable("versionMigrations", { version: varchar("version").primaryKey(), executedAt: bigint("executedAt", { mode: "number" }).notNull() }); export const resourceRules = pgTable("resourceRules", { ruleId: serial("ruleId").primaryKey(), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), enabled: boolean("enabled").notNull().default(true), priority: integer("priority").notNull(), action: varchar("action").notNull(), // ACCEPT, DROP, PASS match: varchar("match").notNull(), // CIDR, PATH, IP value: varchar("value").notNull() }); export const supporterKey = pgTable("supporterKey", { keyId: serial("keyId").primaryKey(), key: varchar("key").notNull(), githubUsername: varchar("githubUsername").notNull(), phrase: varchar("phrase"), tier: varchar("tier"), valid: boolean("valid").notNull().default(false) }); export const idp = pgTable("idp", { idpId: serial("idpId").primaryKey(), name: varchar("name").notNull(), type: varchar("type").notNull(), defaultRoleMapping: varchar("defaultRoleMapping"), defaultOrgMapping: varchar("defaultOrgMapping"), autoProvision: boolean("autoProvision").notNull().default(false), tags: text("tags") }); export const idpOidcConfig = pgTable("idpOidcConfig", { idpOauthConfigId: serial("idpOauthConfigId").primaryKey(), idpId: integer("idpId") .notNull() .references(() => idp.idpId, { onDelete: "cascade" }), variant: varchar("variant").notNull().default("oidc"), clientId: varchar("clientId").notNull(), clientSecret: varchar("clientSecret").notNull(), authUrl: varchar("authUrl").notNull(), tokenUrl: varchar("tokenUrl").notNull(), identifierPath: varchar("identifierPath").notNull(), emailPath: varchar("emailPath"), namePath: varchar("namePath"), scopes: varchar("scopes").notNull() }); export const licenseKey = pgTable("licenseKey", { licenseKeyId: varchar("licenseKeyId").primaryKey().notNull(), instanceId: varchar("instanceId").notNull(), token: varchar("token").notNull() }); export const hostMeta = pgTable("hostMeta", { hostMetaId: varchar("hostMetaId").primaryKey().notNull(), createdAt: bigint("createdAt", { mode: "number" }).notNull() }); export const apiKeys = pgTable("apiKeys", { apiKeyId: varchar("apiKeyId").primaryKey(), name: varchar("name").notNull(), apiKeyHash: varchar("apiKeyHash").notNull(), lastChars: varchar("lastChars").notNull(), createdAt: varchar("dateCreated").notNull(), isRoot: boolean("isRoot").notNull().default(false) }); export const apiKeyActions = pgTable("apiKeyActions", { apiKeyId: varchar("apiKeyId") .notNull() .references(() => apiKeys.apiKeyId, { onDelete: "cascade" }), actionId: varchar("actionId") .notNull() .references(() => actions.actionId, { onDelete: "cascade" }) }); export const apiKeyOrg = pgTable("apiKeyOrg", { apiKeyId: varchar("apiKeyId") .notNull() .references(() => apiKeys.apiKeyId, { onDelete: "cascade" }), orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull() }); export const idpOrg = pgTable("idpOrg", { idpId: integer("idpId") .notNull() .references(() => idp.idpId, { onDelete: "cascade" }), orgId: varchar("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), roleMapping: varchar("roleMapping"), orgMapping: varchar("orgMapping") }); export const clients = pgTable("clients", { clientId: serial("clientId").primaryKey(), orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" }), niceId: varchar("niceId").notNull(), olmId: text("olmId"), // to lock it to a specific olm optionally name: varchar("name").notNull(), pubKey: varchar("pubKey"), subnet: varchar("subnet").notNull(), megabytesIn: real("bytesIn"), megabytesOut: real("bytesOut"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"), lastPing: integer("lastPing"), type: varchar("type").notNull(), // "olm" online: boolean("online").notNull().default(false), // endpoint: varchar("endpoint"), lastHolePunch: integer("lastHolePunch"), maxConnections: integer("maxConnections"), archived: boolean("archived").notNull().default(false), blocked: boolean("blocked").notNull().default(false), approvalState: varchar("approvalState").$type< "pending" | "approved" | "denied" >() }); export const clientSitesAssociationsCache = pgTable( "clientSitesAssociationsCache", { clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message .notNull(), siteId: integer("siteId").notNull(), isRelayed: boolean("isRelayed").notNull().default(false), isJitMode: boolean("isJitMode").notNull().default(false), endpoint: varchar("endpoint"), publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes } ); export const clientSiteResourcesAssociationsCache = pgTable( "clientSiteResourcesAssociationsCache", { clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message .notNull(), siteResourceId: integer("siteResourceId").notNull() } ); export const clientPostureSnapshots = pgTable("clientPostureSnapshots", { snapshotId: serial("snapshotId").primaryKey(), clientId: integer("clientId").references(() => clients.clientId, { onDelete: "cascade" }), collectedAt: integer("collectedAt").notNull() }); export const olms = pgTable("olms", { olmId: varchar("id").primaryKey(), secretHash: varchar("secretHash").notNull(), dateCreated: varchar("dateCreated").notNull(), version: text("version"), agent: text("agent"), name: varchar("name"), clientId: integer("clientId").references(() => clients.clientId, { // we will switch this depending on the current org it wants to connect to onDelete: "set null" }), userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" }), archived: boolean("archived").notNull().default(false) }); export const currentFingerprint = pgTable("currentFingerprint", { fingerprintId: serial("id").primaryKey(), olmId: text("olmId") .references(() => olms.olmId, { onDelete: "cascade" }) .notNull(), firstSeen: integer("firstSeen").notNull(), lastSeen: integer("lastSeen").notNull(), lastCollectedAt: integer("lastCollectedAt").notNull(), username: text("username"), hostname: text("hostname"), platform: text("platform"), osVersion: text("osVersion"), kernelVersion: text("kernelVersion"), arch: text("arch"), deviceModel: text("deviceModel"), serialNumber: text("serialNumber"), platformFingerprint: varchar("platformFingerprint"), // Platform-agnostic checks biometricsEnabled: boolean("biometricsEnabled").notNull().default(false), diskEncrypted: boolean("diskEncrypted").notNull().default(false), firewallEnabled: boolean("firewallEnabled").notNull().default(false), autoUpdatesEnabled: boolean("autoUpdatesEnabled").notNull().default(false), tpmAvailable: boolean("tpmAvailable").notNull().default(false), // Windows-specific posture check information windowsAntivirusEnabled: boolean("windowsAntivirusEnabled") .notNull() .default(false), // macOS-specific posture check information macosSipEnabled: boolean("macosSipEnabled").notNull().default(false), macosGatekeeperEnabled: boolean("macosGatekeeperEnabled") .notNull() .default(false), macosFirewallStealthMode: boolean("macosFirewallStealthMode") .notNull() .default(false), // Linux-specific posture check information linuxAppArmorEnabled: boolean("linuxAppArmorEnabled") .notNull() .default(false), linuxSELinuxEnabled: boolean("linuxSELinuxEnabled").notNull().default(false) }); export const fingerprintSnapshots = pgTable("fingerprintSnapshots", { snapshotId: serial("id").primaryKey(), fingerprintId: integer("fingerprintId").references( () => currentFingerprint.fingerprintId, { onDelete: "set null" } ), username: text("username"), hostname: text("hostname"), platform: text("platform"), osVersion: text("osVersion"), kernelVersion: text("kernelVersion"), arch: text("arch"), deviceModel: text("deviceModel"), serialNumber: text("serialNumber"), platformFingerprint: varchar("platformFingerprint"), // Platform-agnostic checks biometricsEnabled: boolean("biometricsEnabled").notNull().default(false), diskEncrypted: boolean("diskEncrypted").notNull().default(false), firewallEnabled: boolean("firewallEnabled").notNull().default(false), autoUpdatesEnabled: boolean("autoUpdatesEnabled").notNull().default(false), tpmAvailable: boolean("tpmAvailable").notNull().default(false), // Windows-specific posture check information windowsAntivirusEnabled: boolean("windowsAntivirusEnabled") .notNull() .default(false), // macOS-specific posture check information macosSipEnabled: boolean("macosSipEnabled").notNull().default(false), macosGatekeeperEnabled: boolean("macosGatekeeperEnabled") .notNull() .default(false), macosFirewallStealthMode: boolean("macosFirewallStealthMode") .notNull() .default(false), // Linux-specific posture check information linuxAppArmorEnabled: boolean("linuxAppArmorEnabled") .notNull() .default(false), linuxSELinuxEnabled: boolean("linuxSELinuxEnabled") .notNull() .default(false), hash: text("hash").notNull(), collectedAt: integer("collectedAt").notNull() }); export const olmSessions = pgTable("clientSession", { sessionId: varchar("id").primaryKey(), olmId: varchar("olmId") .notNull() .references(() => olms.olmId, { onDelete: "cascade" }), expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); export const userClients = pgTable("userClients", { userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), clientId: integer("clientId") .notNull() .references(() => clients.clientId, { onDelete: "cascade" }) }); export const roleClients = pgTable("roleClients", { roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }), clientId: integer("clientId") .notNull() .references(() => clients.clientId, { onDelete: "cascade" }) }); export const securityKeys = pgTable("webauthnCredentials", { credentialId: varchar("credentialId").primaryKey(), userId: varchar("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), publicKey: varchar("publicKey").notNull(), signCount: integer("signCount").notNull(), transports: varchar("transports"), name: varchar("name"), lastUsed: varchar("lastUsed").notNull(), dateCreated: varchar("dateCreated").notNull(), securityKeyName: varchar("securityKeyName") }); export const webauthnChallenge = pgTable("webauthnChallenge", { sessionId: varchar("sessionId").primaryKey(), challenge: varchar("challenge").notNull(), securityKeyName: varchar("securityKeyName"), userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }), expiresAt: bigint("expiresAt", { mode: "number" }).notNull() // Unix timestamp }); export const setupTokens = pgTable("setupTokens", { tokenId: varchar("tokenId").primaryKey(), token: varchar("token").notNull(), used: boolean("used").notNull().default(false), dateCreated: varchar("dateCreated").notNull(), dateUsed: varchar("dateUsed") }); // Blueprint runs export const blueprints = pgTable("blueprints", { blueprintId: serial("blueprintId").primaryKey(), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), name: varchar("name").notNull(), source: varchar("source").notNull(), createdAt: integer("createdAt").notNull(), succeeded: boolean("succeeded").notNull(), contents: text("contents").notNull(), message: text("message") }); export const requestAuditLog = pgTable( "requestAuditLog", { id: serial("id").primaryKey(), timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds orgId: text("orgId").references(() => orgs.orgId, { onDelete: "cascade" }), action: boolean("action").notNull(), reason: integer("reason").notNull(), actorType: text("actorType"), actor: text("actor"), actorId: text("actorId"), resourceId: integer("resourceId"), ip: text("ip"), location: text("location"), userAgent: text("userAgent"), metadata: text("metadata"), headers: text("headers"), // JSON blob query: text("query"), // JSON blob originalRequestURL: text("originalRequestURL"), scheme: text("scheme"), host: text("host"), path: text("path"), method: text("method"), tls: boolean("tls") }, (table) => [ index("idx_requestAuditLog_timestamp").on(table.timestamp), index("idx_requestAuditLog_org_timestamp").on( table.orgId, table.timestamp ) ] ); export const deviceWebAuthCodes = pgTable("deviceWebAuthCodes", { codeId: serial("codeId").primaryKey(), code: text("code").notNull().unique(), ip: text("ip"), city: text("city"), deviceName: text("deviceName"), applicationName: text("applicationName").notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), createdAt: bigint("createdAt", { mode: "number" }).notNull(), verified: boolean("verified").notNull().default(false), userId: varchar("userId").references(() => users.userId, { onDelete: "cascade" }) }); 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; export type User = InferSelectModel; export type Site = InferSelectModel; export type Resource = InferSelectModel; export type ExitNode = InferSelectModel; export type Target = InferSelectModel; export type Session = InferSelectModel; export type Newt = InferSelectModel; export type NewtSession = InferSelectModel; export type EmailVerificationCode = InferSelectModel< typeof emailVerificationCodes >; export type TwoFactorBackupCode = InferSelectModel; export type PasswordResetToken = InferSelectModel; export type Role = InferSelectModel; export type Action = InferSelectModel; export type RoleAction = InferSelectModel; export type UserAction = InferSelectModel; export type RoleSite = InferSelectModel; export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; export type UserInvite = InferSelectModel; export type UserOrg = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; export type ResourceHeaderAuth = InferSelectModel; export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel< typeof resourceHeaderAuthExtendedCompatibility >; export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; export type SupporterKey = InferSelectModel; export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; export type Client = InferSelectModel; export type ClientSite = InferSelectModel; export type Olm = InferSelectModel; export type OlmSession = InferSelectModel; export type UserClient = InferSelectModel; export type RoleClient = InferSelectModel; export type OrgDomains = InferSelectModel; export type SiteResource = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; export type Blueprint = InferSelectModel; export type LicenseKey = InferSelectModel; export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; export type DeviceWebAuthCode = InferSelectModel; export type RequestAuditLog = InferSelectModel; export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; ================================================ FILE: server/db/queries/verifySessionQueries.ts ================================================ import { db, loginPage, LoginPage, loginPageOrg, Org, orgs, roles } from "@server/db"; import { Resource, ResourcePassword, ResourcePincode, ResourceRule, resourcePassword, resourcePincode, resourceHeaderAuth, ResourceHeaderAuth, resourceRules, resources, roleResources, sessions, userOrgs, userResources, users, ResourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility } from "@server/db"; import { and, eq } from "drizzle-orm"; export type ResourceWithAuth = { resource: Resource | null; pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; org: Org; }; export type UserSessionWithUser = { session: any; user: any; }; /** * Get resource by domain with pincode and password information */ export async function getResourceByDomain( domain: string ): Promise { const [result] = await db .select() .from(resources) .leftJoin( resourcePincode, eq(resourcePincode.resourceId, resources.resourceId) ) .leftJoin( resourcePassword, eq(resourcePassword.resourceId, resources.resourceId) ) .leftJoin( resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) .leftJoin( resourceHeaderAuthExtendedCompatibility, eq( resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId ) ) .innerJoin(orgs, eq(orgs.orgId, resources.orgId)) .where(eq(resources.fullDomain, domain)) .limit(1); if (!result) { return null; } return { resource: result.resources, pincode: result.resourcePincode, password: result.resourcePassword, headerAuth: result.resourceHeaderAuth, headerAuthExtendedCompatibility: result.resourceHeaderAuthExtendedCompatibility, org: result.orgs }; } /** * Get user session with user information */ export async function getUserSessionWithUser( userSessionId: string ): Promise { const [res] = await db .select() .from(sessions) .leftJoin(users, eq(users.userId, sessions.userId)) .where(eq(sessions.sessionId, userSessionId)); if (!res) { return null; } return { session: res.session, user: res.user }; } /** * Get user organization role */ export async function getUserOrgRole(userId: string, orgId: string) { const userOrgRole = await db .select({ userId: userOrgs.userId, orgId: userOrgs.orgId, roleId: userOrgs.roleId, isOwner: userOrgs.isOwner, autoProvisioned: userOrgs.autoProvisioned, roleName: roles.name }) .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .limit(1); return userOrgRole.length > 0 ? userOrgRole[0] : null; } /** * Check if role has access to resource */ export async function getRoleResourceAccess( resourceId: number, roleId: number ) { const roleResourceAccess = await db .select() .from(roleResources) .where( and( eq(roleResources.resourceId, resourceId), eq(roleResources.roleId, roleId) ) ) .limit(1); return roleResourceAccess.length > 0 ? roleResourceAccess[0] : null; } /** * Check if user has direct access to resource */ export async function getUserResourceAccess( userId: string, resourceId: number ) { const userResourceAccess = await db .select() .from(userResources) .where( and( eq(userResources.userId, userId), eq(userResources.resourceId, resourceId) ) ) .limit(1); return userResourceAccess.length > 0 ? userResourceAccess[0] : null; } /** * Get resource rules for a given resource */ export async function getResourceRules( resourceId: number ): Promise { const rules = await db .select() .from(resourceRules) .where(eq(resourceRules.resourceId, resourceId)); return rules; } /** * Get organization login page */ export async function getOrgLoginPage( orgId: string ): Promise { const [result] = await db .select() .from(loginPageOrg) .where(eq(loginPageOrg.orgId, orgId)) .innerJoin( loginPage, eq(loginPageOrg.loginPageId, loginPage.loginPageId) ) .limit(1); if (!result) { return null; } return result?.loginPage; } ================================================ FILE: server/db/sqlite/driver.ts ================================================ import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; import * as schema from "./schema/schema"; import path from "path"; import fs from "fs"; import { APP_PATH } from "@server/lib/consts"; import { existsSync, mkdirSync } from "fs"; export const location = path.join(APP_PATH, "db", "db.sqlite"); export const exists = checkFileExists(location); bootstrapVolume(); function createDb() { const sqlite = new Database(location); return DrizzleSqlite(sqlite, { schema }); } export const db = createDb(); export default db; export const primaryDb = db; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] >[0]; function checkFileExists(filePath: string): boolean { try { fs.accessSync(filePath); return true; } catch { return false; } } function bootstrapVolume() { const appPath = APP_PATH; const dbDir = path.join(appPath, "db"); const logsDir = path.join(appPath, "logs"); // check if the db directory exists and create it if it doesn't if (!existsSync(dbDir)) { mkdirSync(dbDir, { recursive: true }); } // check if the logs directory exists and create it if it doesn't if (!existsSync(logsDir)) { mkdirSync(logsDir, { recursive: true }); } // THIS IS FOR TRAEFIK; NOT REALLY NEEDED, BUT JUST IN CASE const traefikDir = path.join(appPath, "traefik"); // check if the traefik directory exists and create it if it doesn't if (!existsSync(traefikDir)) { mkdirSync(traefikDir, { recursive: true }); } } ================================================ FILE: server/db/sqlite/index.ts ================================================ export * from "./driver"; export * from "./logsDriver"; export * from "./safeRead"; export * from "./schema/schema"; export * from "./schema/privateSchema"; export * from "./migrate"; ================================================ FILE: server/db/sqlite/logsDriver.ts ================================================ import { db as mainDb } from "./driver"; // SQLite doesn't support separate databases for logs in the same way as Postgres // Always use the main database connection for SQLite export const logsDb = mainDb; export default logsDb; export const primaryLogsDb = logsDb; ================================================ FILE: server/db/sqlite/migrate.ts ================================================ import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { db } from "./driver"; import path from "path"; const migrationsFolder = path.join("server/migrations"); export const runMigrations = async () => { console.log("Running migrations..."); try { migrate(db as any, { migrationsFolder: migrationsFolder }); console.log("Migrations completed successfully."); } catch (error) { console.error("Error running migrations:", error); process.exit(1); } }; ================================================ FILE: server/db/sqlite/safeRead.ts ================================================ import { db } from "./driver"; /** * Runs a read query. For SQLite there is no replica/primary distinction, * so the query is executed once against the database. */ export async function safeRead( query: (d: typeof db) => Promise ): Promise { return query(db); } ================================================ FILE: server/db/sqlite/schema/privateSchema.ts ================================================ import { InferSelectModel } from "drizzle-orm"; import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { clients, domains, exitNodes, orgs, sessions, users } from "./schema"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), domain: text("domain").notNull().unique(), domainId: text("domainId").references(() => domains.domainId, { onDelete: "cascade" }), wildcard: integer("wildcard", { mode: "boolean" }).default(false), status: text("status").notNull().default("pending"), // pending, requested, valid, expired, failed expiresAt: integer("expiresAt"), lastRenewalAttempt: integer("lastRenewalAttempt"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt").notNull(), orderId: text("orderId"), errorMessage: text("errorMessage"), renewalCount: integer("renewalCount").default(0), certFile: text("certFile"), keyFile: text("keyFile") }); export const dnsChallenge = sqliteTable("dnsChallenges", { dnsChallengeId: integer("dnsChallengeId").primaryKey({ autoIncrement: true }), domain: text("domain").notNull(), token: text("token").notNull(), keyAuthorization: text("keyAuthorization").notNull(), createdAt: integer("createdAt").notNull(), expiresAt: integer("expiresAt").notNull(), completed: integer("completed", { mode: "boolean" }).default(false) }); export const account = sqliteTable("account", { accountId: integer("accountId").primaryKey({ autoIncrement: true }), userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }) }); export const customers = sqliteTable("customers", { customerId: text("customerId").primaryKey().notNull(), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), // accountId: integer("accountId") // .references(() => account.accountId, { onDelete: "cascade" }), // Optional, if using accounts email: text("email"), name: text("name"), phone: text("phone"), address: text("address"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt").notNull() }); export const subscriptions = sqliteTable("subscriptions", { subscriptionId: text("subscriptionId").primaryKey().notNull(), customerId: text("customerId") .notNull() .references(() => customers.customerId, { onDelete: "cascade" }), status: text("status").notNull().default("active"), // active, past_due, canceled, unpaid canceledAt: integer("canceledAt"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt"), version: integer("version"), billingCycleAnchor: integer("billingCycleAnchor"), type: text("type") // tier1, tier2, tier3, or license }); export const subscriptionItems = sqliteTable("subscriptionItems", { subscriptionItemId: integer("subscriptionItemId").primaryKey({ autoIncrement: true }), stripeSubscriptionItemId: text("stripeSubscriptionItemId"), subscriptionId: text("subscriptionId") .notNull() .references(() => subscriptions.subscriptionId, { onDelete: "cascade" }), planId: text("planId").notNull(), priceId: text("priceId"), featureId: text("featureId"), meterId: text("meterId"), unitAmount: real("unitAmount"), tiers: text("tiers"), interval: text("interval"), currentPeriodStart: integer("currentPeriodStart"), currentPeriodEnd: integer("currentPeriodEnd"), name: text("name") }); export const accountDomains = sqliteTable("accountDomains", { accountId: integer("accountId") .notNull() .references(() => account.accountId, { onDelete: "cascade" }), domainId: text("domainId") .notNull() .references(() => domains.domainId, { onDelete: "cascade" }) }); export const usage = sqliteTable("usage", { usageId: text("usageId").primaryKey(), featureId: text("featureId").notNull(), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), meterId: text("meterId"), instantaneousValue: real("instantaneousValue"), latestValue: real("latestValue").notNull(), previousValue: real("previousValue"), updatedAt: integer("updatedAt").notNull(), rolledOverAt: integer("rolledOverAt"), nextRolloverAt: integer("nextRolloverAt") }); export const limits = sqliteTable("limits", { limitId: text("limitId").primaryKey(), featureId: text("featureId").notNull(), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), value: real("value"), override: integer("override", { mode: "boolean" }).default(false), description: text("description") }); export const usageNotifications = sqliteTable("usageNotifications", { notificationId: integer("notificationId").primaryKey({ autoIncrement: true }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), featureId: text("featureId").notNull(), limitId: text("limitId").notNull(), notificationType: text("notificationType").notNull(), sentAt: integer("sentAt").notNull() }); export const domainNamespaces = sqliteTable("domainNamespaces", { domainNamespaceId: text("domainNamespaceId").primaryKey(), domainId: text("domainId") .references(() => domains.domainId, { onDelete: "set null" }) .notNull() }); export const exitNodeOrgs = sqliteTable("exitNodeOrgs", { exitNodeId: integer("exitNodeId") .notNull() .references(() => exitNodes.exitNodeId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const remoteExitNodes = sqliteTable("remoteExitNode", { remoteExitNodeId: text("id").primaryKey(), secretHash: text("secretHash").notNull(), dateCreated: text("dateCreated").notNull(), version: text("version"), secondaryVersion: text("secondaryVersion"), // This is to detect the new nodes after the transition to pangolin-node exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { onDelete: "cascade" }) }); export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", { sessionId: text("id").primaryKey(), remoteExitNodeId: text("remoteExitNodeId") .notNull() .references(() => remoteExitNodes.remoteExitNodeId, { onDelete: "cascade" }), expiresAt: integer("expiresAt").notNull() }); export const loginPage = sqliteTable("loginPage", { loginPageId: integer("loginPageId").primaryKey({ autoIncrement: true }), subdomain: text("subdomain"), fullDomain: text("fullDomain"), exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), domainId: text("domainId").references(() => domains.domainId, { onDelete: "set null" }) }); export const loginPageOrg = sqliteTable("loginPageOrg", { loginPageId: integer("loginPageId") .notNull() .references(() => loginPage.loginPageId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const loginPageBranding = sqliteTable("loginPageBranding", { loginPageBrandingId: integer("loginPageBrandingId").primaryKey({ autoIncrement: true }), logoUrl: text("logoUrl"), logoWidth: integer("logoWidth").notNull(), logoHeight: integer("logoHeight").notNull(), primaryColor: text("primaryColor"), resourceTitle: text("resourceTitle").notNull(), resourceSubtitle: text("resourceSubtitle"), orgTitle: text("orgTitle"), orgSubtitle: text("orgSubtitle") }); export const loginPageBrandingOrg = sqliteTable("loginPageBrandingOrg", { loginPageBrandingId: integer("loginPageBrandingId") .notNull() .references(() => loginPageBranding.loginPageBrandingId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const sessionTransferToken = sqliteTable("sessionTransferToken", { token: text("token").primaryKey(), sessionId: text("sessionId") .notNull() .references(() => sessions.sessionId, { onDelete: "cascade" }), encryptedSession: text("encryptedSession").notNull(), expiresAt: integer("expiresAt").notNull() }); export const actionAuditLog = sqliteTable( "actionAuditLog", { id: integer("id").primaryKey({ autoIncrement: true }), timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), actorType: text("actorType").notNull(), actor: text("actor").notNull(), actorId: text("actorId").notNull(), action: text("action").notNull(), metadata: text("metadata") }, (table) => [ index("idx_actionAuditLog_timestamp").on(table.timestamp), index("idx_actionAuditLog_org_timestamp").on( table.orgId, table.timestamp ) ] ); export const accessAuditLog = sqliteTable( "accessAuditLog", { id: integer("id").primaryKey({ autoIncrement: true }), timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), actorType: text("actorType"), actor: text("actor"), actorId: text("actorId"), resourceId: integer("resourceId"), ip: text("ip"), location: text("location"), type: text("type").notNull(), action: integer("action", { mode: "boolean" }).notNull(), userAgent: text("userAgent"), metadata: text("metadata") }, (table) => [ index("idx_identityAuditLog_timestamp").on(table.timestamp), index("idx_identityAuditLog_org_timestamp").on( table.orgId, table.timestamp ) ] ); export const approvals = sqliteTable("approvals", { approvalId: integer("approvalId").primaryKey({ autoIncrement: true }), timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), clientId: integer("clientId").references(() => clients.clientId, { onDelete: "cascade" }), // olms reference user devices clients userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" }), decision: text("decision") .$type<"approved" | "denied" | "pending">() .default("pending") .notNull(), type: text("type") .$type<"user_device" /*| 'proxy' // for later */>() .notNull() }); export const bannedEmails = sqliteTable("bannedEmails", { email: text("email").primaryKey() }); export const bannedIps = sqliteTable("bannedIps", { ip: text("ip").primaryKey() }); export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; export type DnsChallenge = InferSelectModel; export type Customer = InferSelectModel; export type Subscription = InferSelectModel; export type SubscriptionItem = InferSelectModel; export type Usage = InferSelectModel; export type UsageLimit = InferSelectModel; export type AccountDomain = InferSelectModel; export type UsageNotification = InferSelectModel; export type RemoteExitNode = InferSelectModel; export type RemoteExitNodeSession = InferSelectModel< typeof remoteExitNodeSessions >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; export type LoginPageBranding = InferSelectModel; export type ActionAuditLog = InferSelectModel; export type AccessAuditLog = InferSelectModel; ================================================ FILE: server/db/sqlite/schema/schema.ts ================================================ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), baseDomain: text("baseDomain").notNull(), configManaged: integer("configManaged", { mode: "boolean" }) .notNull() .default(false), type: text("type"), // "ns", "cname", "wildcard" verified: integer("verified", { mode: "boolean" }).notNull().default(false), failed: integer("failed", { mode: "boolean" }).notNull().default(false), tries: integer("tries").notNull().default(0), certResolver: text("certResolver"), preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }), errorMessage: text("errorMessage") }); export const dnsRecords = sqliteTable("dnsRecords", { id: integer("id").primaryKey({ autoIncrement: true }), domainId: text("domainId") .notNull() .references(() => domains.domainId, { onDelete: "cascade" }), recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" baseDomain: text("baseDomain"), value: text("value").notNull(), verified: integer("verified", { mode: "boolean" }).notNull().default(false) }); export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), name: text("name").notNull(), subnet: text("subnet"), utilitySubnet: text("utilitySubnet"), // this is the subnet for utility addresses createdAt: text("createdAt"), requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }), maxSessionLengthHours: integer("maxSessionLengthHours"), // hours passwordExpiryDays: integer("passwordExpiryDays"), // days settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(7), settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .default(0), settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() .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", { userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), domainId: text("domainId") .notNull() .references(() => domains.domainId, { onDelete: "cascade" }) }); export const orgDomains = sqliteTable("orgDomains", { orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), domainId: text("domainId") .notNull() .references(() => domains.domainId, { onDelete: "cascade" }) }); export const sites = sqliteTable("sites", { siteId: integer("siteId").primaryKey({ autoIncrement: true }), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), niceId: text("niceId").notNull(), exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), name: text("name").notNull(), pubKey: text("pubKey"), subnet: text("subnet"), megabytesIn: integer("bytesIn").default(0), megabytesOut: integer("bytesOut").default(0), lastBandwidthUpdate: text("lastBandwidthUpdate"), type: text("type").notNull(), // "newt" or "wireguard" online: integer("online", { mode: "boolean" }).notNull().default(false), lastPing: integer("lastPing"), // exit node stuff that is how to connect to the site when it has a wg server address: text("address"), // this is the address of the wireguard interface in newt endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config publicKey: text("publicKey"), // TODO: Fix typo in publicKey lastHolePunch: integer("lastHolePunch"), listenPort: integer("listenPort"), dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) .notNull() .default(true) }); export const resources = sqliteTable("resources", { resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), resourceGuid: text("resourceGuid", { length: 36 }) .unique() .notNull() .$defaultFn(() => randomUUID()), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), niceId: text("niceId").notNull(), name: text("name").notNull(), subdomain: text("subdomain"), fullDomain: text("fullDomain"), domainId: text("domainId").references(() => domains.domainId, { onDelete: "set null" }), ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), blockAccess: integer("blockAccess", { mode: "boolean" }) .notNull() .default(false), sso: integer("sso", { mode: "boolean" }).notNull().default(true), http: integer("http", { mode: "boolean" }).notNull().default(true), protocol: text("protocol").notNull(), proxyPort: integer("proxyPort"), emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) .notNull() .default(false), applyRules: integer("applyRules", { mode: "boolean" }) .notNull() .default(false), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), stickySession: integer("stickySession", { mode: "boolean" }) .notNull() .default(false), tlsServerName: text("tlsServerName"), setHostHeader: text("setHostHeader"), enableProxy: integer("enableProxy", { mode: "boolean" }).default(true), skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "set null" }), headers: text("headers"), // comma-separated list of headers to add to the request proxyProtocol: integer("proxyProtocol", { mode: "boolean" }) .notNull() .default(false), proxyProtocolVersion: integer("proxyProtocolVersion").default(1), maintenanceModeEnabled: integer("maintenanceModeEnabled", { mode: "boolean" }) .notNull() .default(false), maintenanceModeType: text("maintenanceModeType", { enum: ["forced", "automatic"] }).default("forced"), // "forced" = always show, "automatic" = only when down maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"), postAuthPath: text("postAuthPath") }); export const targets = sqliteTable("targets", { targetId: integer("targetId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId") .references(() => resources.resourceId, { onDelete: "cascade" }) .notNull(), siteId: integer("siteId") .references(() => sites.siteId, { onDelete: "cascade" }) .notNull(), ip: text("ip").notNull(), method: text("method"), port: integer("port").notNull(), internalPort: integer("internalPort"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), path: text("path"), pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix priority: integer("priority").notNull().default(100) }); export const targetHealthCheck = sqliteTable("targetHealthCheck", { targetHealthCheckId: integer("targetHealthCheckId").primaryKey({ autoIncrement: true }), targetId: integer("targetId") .notNull() .references(() => targets.targetId, { onDelete: "cascade" }), hcEnabled: integer("hcEnabled", { mode: "boolean" }) .notNull() .default(false), hcPath: text("hcPath"), hcScheme: text("hcScheme"), hcMode: text("hcMode").default("http"), hcHostname: text("hcHostname"), hcPort: integer("hcPort"), hcInterval: integer("hcInterval").default(30), // in seconds hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds hcTimeout: integer("hcTimeout").default(5), // in seconds hcHeaders: text("hcHeaders"), hcFollowRedirects: integer("hcFollowRedirects", { mode: "boolean" }).default(true), hcMethod: text("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code hcHealth: text("hcHealth") .$type<"unknown" | "healthy" | "unhealthy">() .default("unknown"), // "unknown", "healthy", "unhealthy" hcTlsServerName: text("hcTlsServerName") }); export const exitNodes = sqliteTable("exitNodes", { exitNodeId: integer("exitNodeId").primaryKey({ autoIncrement: true }), name: text("name").notNull(), address: text("address").notNull(), // this is the address of the wireguard interface in gerbil endpoint: text("endpoint").notNull(), // this is how to reach gerbil externally - gets put into the wireguard config publicKey: text("publicKey").notNull(), listenPort: integer("listenPort").notNull(), reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control maxConnections: integer("maxConnections"), online: integer("online", { mode: "boolean" }).notNull().default(false), lastPing: integer("lastPing"), type: text("type").default("gerbil"), // gerbil, remoteExitNode region: text("region") }); export const siteResources = sqliteTable("siteResources", { // this is for the clients siteResourceId: integer("siteResourceId").primaryKey({ autoIncrement: true }), siteId: integer("siteId") .notNull() .references(() => sites.siteId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: text("niceId").notNull(), name: text("name").notNull(), 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 destination: text("destination").notNull(), // ip, cidr, hostname enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), alias: text("alias"), aliasAddress: text("aliasAddress"), tcpPortRangeString: text("tcpPortRangeString").notNull().default("*"), udpPortRangeString: text("udpPortRangeString").notNull().default("*"), disableIcmp: integer("disableIcmp", { mode: "boolean" }) .notNull() .default(false), authDaemonPort: integer("authDaemonPort").default(22123), authDaemonMode: text("authDaemonMode") .$type<"site" | "remote">() .default("site") }); export const clientSiteResources = sqliteTable("clientSiteResources", { clientId: integer("clientId") .notNull() .references(() => clients.clientId, { onDelete: "cascade" }), siteResourceId: integer("siteResourceId") .notNull() .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) }); export const roleSiteResources = sqliteTable("roleSiteResources", { roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }), siteResourceId: integer("siteResourceId") .notNull() .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) }); export const userSiteResources = sqliteTable("userSiteResources", { userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), siteResourceId: integer("siteResourceId") .notNull() .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) }); export const users = sqliteTable("user", { userId: text("id").primaryKey(), email: text("email"), username: text("username").notNull(), name: text("name"), type: text("type").notNull(), // "internal", "oidc" idpId: integer("idpId").references(() => idp.idpId, { onDelete: "cascade" }), passwordHash: text("passwordHash"), twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" }) .notNull() .default(false), twoFactorSetupRequested: integer("twoFactorSetupRequested", { mode: "boolean" }).default(false), twoFactorSecret: text("twoFactorSecret"), emailVerified: integer("emailVerified", { mode: "boolean" }) .notNull() .default(false), dateCreated: text("dateCreated").notNull(), termsAcceptedTimestamp: text("termsAcceptedTimestamp"), termsVersion: text("termsVersion"), marketingEmailConsent: integer("marketingEmailConsent", { mode: "boolean" }).default(false), serverAdmin: integer("serverAdmin", { mode: "boolean" }) .notNull() .default(false), lastPasswordChange: integer("lastPasswordChange") }); export const securityKeys = sqliteTable("webauthnCredentials", { credentialId: text("credentialId").primaryKey(), userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), publicKey: text("publicKey").notNull(), signCount: integer("signCount").notNull(), transports: text("transports"), name: text("name"), lastUsed: text("lastUsed").notNull(), dateCreated: text("dateCreated").notNull() }); export const webauthnChallenge = sqliteTable("webauthnChallenge", { sessionId: text("sessionId").primaryKey(), challenge: text("challenge").notNull(), securityKeyName: text("securityKeyName"), userId: text("userId").references(() => users.userId, { onDelete: "cascade" }), expiresAt: integer("expiresAt").notNull() // Unix timestamp }); export const setupTokens = sqliteTable("setupTokens", { tokenId: text("tokenId").primaryKey(), token: text("token").notNull(), used: integer("used", { mode: "boolean" }).notNull().default(false), dateCreated: text("dateCreated").notNull(), dateUsed: text("dateUsed") }); export const newts = sqliteTable("newt", { newtId: text("id").primaryKey(), secretHash: text("secretHash").notNull(), dateCreated: text("dateCreated").notNull(), version: text("version"), siteId: integer("siteId").references(() => sites.siteId, { onDelete: "cascade" }) }); export const clients = sqliteTable("clients", { clientId: integer("clientId").primaryKey({ autoIncrement: true }), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { onDelete: "set null" }), userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" }), niceId: text("niceId").notNull(), name: text("name").notNull(), pubKey: text("pubKey"), olmId: text("olmId"), // to lock it to a specific olm optionally subnet: text("subnet").notNull(), megabytesIn: integer("bytesIn"), megabytesOut: integer("bytesOut"), lastBandwidthUpdate: text("lastBandwidthUpdate"), lastPing: integer("lastPing"), type: text("type").notNull(), // "olm" online: integer("online", { mode: "boolean" }).notNull().default(false), // endpoint: text("endpoint"), lastHolePunch: integer("lastHolePunch"), archived: integer("archived", { mode: "boolean" }).notNull().default(false), blocked: integer("blocked", { mode: "boolean" }).notNull().default(false), approvalState: text("approvalState").$type< "pending" | "approved" | "denied" >() }); export const clientSitesAssociationsCache = sqliteTable( "clientSitesAssociationsCache", { clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message .notNull(), siteId: integer("siteId").notNull(), isRelayed: integer("isRelayed", { mode: "boolean" }) .notNull() .default(false), isJitMode: integer("isJitMode", { mode: "boolean" }) .notNull() .default(false), endpoint: text("endpoint"), publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes } ); export const clientSiteResourcesAssociationsCache = sqliteTable( "clientSiteResourcesAssociationsCache", { clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message .notNull(), siteResourceId: integer("siteResourceId").notNull() } ); export const olms = sqliteTable("olms", { olmId: text("id").primaryKey(), secretHash: text("secretHash").notNull(), dateCreated: text("dateCreated").notNull(), version: text("version"), agent: text("agent"), name: text("name"), clientId: integer("clientId").references(() => clients.clientId, { // we will switch this depending on the current org it wants to connect to onDelete: "set null" }), userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" }), archived: integer("archived", { mode: "boolean" }).notNull().default(false) }); export const currentFingerprint = sqliteTable("currentFingerprint", { fingerprintId: integer("id").primaryKey({ autoIncrement: true }), olmId: text("olmId") .references(() => olms.olmId, { onDelete: "cascade" }) .notNull(), firstSeen: integer("firstSeen").notNull(), lastSeen: integer("lastSeen").notNull(), lastCollectedAt: integer("lastCollectedAt").notNull(), username: text("username"), hostname: text("hostname"), platform: text("platform"), osVersion: text("osVersion"), kernelVersion: text("kernelVersion"), arch: text("arch"), deviceModel: text("deviceModel"), serialNumber: text("serialNumber"), platformFingerprint: text("platformFingerprint"), // Platform-agnostic checks biometricsEnabled: integer("biometricsEnabled", { mode: "boolean" }) .notNull() .default(false), diskEncrypted: integer("diskEncrypted", { mode: "boolean" }) .notNull() .default(false), firewallEnabled: integer("firewallEnabled", { mode: "boolean" }) .notNull() .default(false), autoUpdatesEnabled: integer("autoUpdatesEnabled", { mode: "boolean" }) .notNull() .default(false), tpmAvailable: integer("tpmAvailable", { mode: "boolean" }) .notNull() .default(false), // Windows-specific posture check information windowsAntivirusEnabled: integer("windowsAntivirusEnabled", { mode: "boolean" }) .notNull() .default(false), // macOS-specific posture check information macosSipEnabled: integer("macosSipEnabled", { mode: "boolean" }) .notNull() .default(false), macosGatekeeperEnabled: integer("macosGatekeeperEnabled", { mode: "boolean" }) .notNull() .default(false), macosFirewallStealthMode: integer("macosFirewallStealthMode", { mode: "boolean" }) .notNull() .default(false), // Linux-specific posture check information linuxAppArmorEnabled: integer("linuxAppArmorEnabled", { mode: "boolean" }) .notNull() .default(false), linuxSELinuxEnabled: integer("linuxSELinuxEnabled", { mode: "boolean" }) .notNull() .default(false) }); export const fingerprintSnapshots = sqliteTable("fingerprintSnapshots", { snapshotId: integer("id").primaryKey({ autoIncrement: true }), fingerprintId: integer("fingerprintId").references( () => currentFingerprint.fingerprintId, { onDelete: "set null" } ), username: text("username"), hostname: text("hostname"), platform: text("platform"), osVersion: text("osVersion"), kernelVersion: text("kernelVersion"), arch: text("arch"), deviceModel: text("deviceModel"), serialNumber: text("serialNumber"), platformFingerprint: text("platformFingerprint"), // Platform-agnostic checks biometricsEnabled: integer("biometricsEnabled", { mode: "boolean" }) .notNull() .default(false), diskEncrypted: integer("diskEncrypted", { mode: "boolean" }) .notNull() .default(false), firewallEnabled: integer("firewallEnabled", { mode: "boolean" }) .notNull() .default(false), autoUpdatesEnabled: integer("autoUpdatesEnabled", { mode: "boolean" }) .notNull() .default(false), tpmAvailable: integer("tpmAvailable", { mode: "boolean" }) .notNull() .default(false), // Windows-specific posture check information windowsAntivirusEnabled: integer("windowsAntivirusEnabled", { mode: "boolean" }) .notNull() .default(false), // macOS-specific posture check information macosSipEnabled: integer("macosSipEnabled", { mode: "boolean" }) .notNull() .default(false), macosGatekeeperEnabled: integer("macosGatekeeperEnabled", { mode: "boolean" }) .notNull() .default(false), macosFirewallStealthMode: integer("macosFirewallStealthMode", { mode: "boolean" }) .notNull() .default(false), // Linux-specific posture check information linuxAppArmorEnabled: integer("linuxAppArmorEnabled", { mode: "boolean" }) .notNull() .default(false), linuxSELinuxEnabled: integer("linuxSELinuxEnabled", { mode: "boolean" }) .notNull() .default(false), hash: text("hash").notNull(), collectedAt: integer("collectedAt").notNull() }); export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { codeId: integer("id").primaryKey({ autoIncrement: true }), userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), codeHash: text("codeHash").notNull() }); export const sessions = sqliteTable("session", { sessionId: text("id").primaryKey(), userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), expiresAt: integer("expiresAt").notNull(), issuedAt: integer("issuedAt"), deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" }) .notNull() .default(false) }); export const newtSessions = sqliteTable("newtSession", { sessionId: text("id").primaryKey(), newtId: text("newtId") .notNull() .references(() => newts.newtId, { onDelete: "cascade" }), expiresAt: integer("expiresAt").notNull() }); export const olmSessions = sqliteTable("clientSession", { sessionId: text("id").primaryKey(), olmId: text("olmId") .notNull() .references(() => olms.olmId, { onDelete: "cascade" }), expiresAt: integer("expiresAt").notNull() }); export const userOrgs = sqliteTable("userOrgs", { userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), roleId: integer("roleId") .notNull() .references(() => roles.roleId), isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), autoProvisioned: integer("autoProvisioned", { mode: "boolean" }).default(false), pamUsername: text("pamUsername") // cleaned username for ssh and such }); export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { codeId: integer("id").primaryKey({ autoIncrement: true }), userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), email: text("email").notNull(), code: text("code").notNull(), expiresAt: integer("expiresAt").notNull() }); export const passwordResetTokens = sqliteTable("passwordResetTokens", { tokenId: integer("id").primaryKey({ autoIncrement: true }), email: text("email").notNull(), userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), tokenHash: text("tokenHash").notNull(), expiresAt: integer("expiresAt").notNull() }); export const actions = sqliteTable("actions", { actionId: text("actionId").primaryKey(), name: text("name"), description: text("description") }); export const roles = sqliteTable("roles", { roleId: integer("roleId").primaryKey({ autoIncrement: true }), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), isAdmin: integer("isAdmin", { mode: "boolean" }), name: text("name").notNull(), description: text("description"), requireDeviceApproval: integer("requireDeviceApproval", { mode: "boolean" }).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", { roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }), actionId: text("actionId") .notNull() .references(() => actions.actionId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const userActions = sqliteTable("userActions", { userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), actionId: text("actionId") .notNull() .references(() => actions.actionId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }) }); export const roleSites = sqliteTable("roleSites", { roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }), siteId: integer("siteId") .notNull() .references(() => sites.siteId, { onDelete: "cascade" }) }); export const userSites = sqliteTable("userSites", { userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), siteId: integer("siteId") .notNull() .references(() => sites.siteId, { onDelete: "cascade" }) }); export const userClients = sqliteTable("userClients", { userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), clientId: integer("clientId") .notNull() .references(() => clients.clientId, { onDelete: "cascade" }) }); export const roleClients = sqliteTable("roleClients", { roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }), clientId: integer("clientId") .notNull() .references(() => clients.clientId, { onDelete: "cascade" }) }); export const roleResources = sqliteTable("roleResources", { roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const userResources = sqliteTable("userResources", { userId: text("userId") .notNull() .references(() => users.userId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const userInvites = sqliteTable("userInvites", { inviteId: text("inviteId").primaryKey(), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), email: text("email").notNull(), expiresAt: integer("expiresAt").notNull(), tokenHash: text("token").notNull(), roleId: integer("roleId") .notNull() .references(() => roles.roleId, { onDelete: "cascade" }) }); export const resourcePincode = sqliteTable("resourcePincode", { pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), pincodeHash: text("pincodeHash").notNull(), digitLength: integer("digitLength").notNull() }); export const resourcePassword = sqliteTable("resourcePassword", { passwordId: integer("passwordId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), passwordHash: text("passwordHash").notNull() }); export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", { headerAuthId: integer("headerAuthId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), headerAuthHash: text("headerAuthHash").notNull() }); export const resourceHeaderAuthExtendedCompatibility = sqliteTable( "resourceHeaderAuthExtendedCompatibility", { headerAuthExtendedCompatibilityId: integer( "headerAuthExtendedCompatibilityId" ).primaryKey({ autoIncrement: true }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), extendedCompatibilityIsActivated: integer( "extendedCompatibilityIsActivated", { mode: "boolean" } ) .notNull() .default(true) } ); export const resourceAccessToken = sqliteTable("resourceAccessToken", { accessTokenId: text("accessTokenId").primaryKey(), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), tokenHash: text("tokenHash").notNull(), sessionLength: integer("sessionLength").notNull(), expiresAt: integer("expiresAt"), title: text("title"), description: text("description"), createdAt: integer("createdAt").notNull() }); export const resourceSessions = sqliteTable("resourceSessions", { sessionId: text("id").primaryKey(), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), expiresAt: integer("expiresAt").notNull(), sessionLength: integer("sessionLength").notNull(), doNotExtend: integer("doNotExtend", { mode: "boolean" }) .notNull() .default(false), isRequestToken: integer("isRequestToken", { mode: "boolean" }), userSessionId: text("userSessionId").references(() => sessions.sessionId, { onDelete: "cascade" }), passwordId: integer("passwordId").references( () => resourcePassword.passwordId, { onDelete: "cascade" } ), pincodeId: integer("pincodeId").references( () => resourcePincode.pincodeId, { onDelete: "cascade" } ), whitelistId: integer("whitelistId").references( () => resourceWhitelist.whitelistId, { onDelete: "cascade" } ), accessTokenId: text("accessTokenId").references( () => resourceAccessToken.accessTokenId, { onDelete: "cascade" } ), issuedAt: integer("issuedAt") }); export const resourceWhitelist = sqliteTable("resourceWhitelist", { whitelistId: integer("id").primaryKey({ autoIncrement: true }), email: text("email").notNull(), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }) }); export const resourceOtp = sqliteTable("resourceOtp", { otpId: integer("otpId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), email: text("email").notNull(), otpHash: text("otpHash").notNull(), expiresAt: integer("expiresAt").notNull() }); export const versionMigrations = sqliteTable("versionMigrations", { version: text("version").primaryKey(), executedAt: integer("executedAt").notNull() }); export const resourceRules = sqliteTable("resourceRules", { ruleId: integer("ruleId").primaryKey({ autoIncrement: true }), resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), priority: integer("priority").notNull(), action: text("action").notNull(), // ACCEPT, DROP, PASS match: text("match").notNull(), // CIDR, PATH, IP value: text("value").notNull() }); export const supporterKey = sqliteTable("supporterKey", { keyId: integer("keyId").primaryKey({ autoIncrement: true }), key: text("key").notNull(), githubUsername: text("githubUsername").notNull(), phrase: text("phrase"), tier: text("tier"), valid: integer("valid", { mode: "boolean" }).notNull().default(false) }); // Identity Providers export const idp = sqliteTable("idp", { idpId: integer("idpId").primaryKey({ autoIncrement: true }), name: text("name").notNull(), type: text("type").notNull(), defaultRoleMapping: text("defaultRoleMapping"), defaultOrgMapping: text("defaultOrgMapping"), autoProvision: integer("autoProvision", { mode: "boolean" }) .notNull() .default(false), tags: text("tags") }); // Identity Provider OAuth Configuration export const idpOidcConfig = sqliteTable("idpOidcConfig", { idpOauthConfigId: integer("idpOauthConfigId").primaryKey({ autoIncrement: true }), variant: text("variant").notNull().default("oidc"), idpId: integer("idpId") .notNull() .references(() => idp.idpId, { onDelete: "cascade" }), clientId: text("clientId").notNull(), clientSecret: text("clientSecret").notNull(), authUrl: text("authUrl").notNull(), tokenUrl: text("tokenUrl").notNull(), identifierPath: text("identifierPath").notNull(), emailPath: text("emailPath"), namePath: text("namePath"), scopes: text("scopes").notNull() }); export const licenseKey = sqliteTable("licenseKey", { licenseKeyId: text("licenseKeyId").primaryKey().notNull(), instanceId: text("instanceId").notNull(), token: text("token").notNull() }); export const hostMeta = sqliteTable("hostMeta", { hostMetaId: text("hostMetaId").primaryKey().notNull(), createdAt: integer("createdAt").notNull() }); export const apiKeys = sqliteTable("apiKeys", { apiKeyId: text("apiKeyId").primaryKey(), name: text("name").notNull(), apiKeyHash: text("apiKeyHash").notNull(), lastChars: text("lastChars").notNull(), createdAt: text("dateCreated").notNull(), isRoot: integer("isRoot", { mode: "boolean" }).notNull().default(false) }); export const apiKeyActions = sqliteTable("apiKeyActions", { apiKeyId: text("apiKeyId") .notNull() .references(() => apiKeys.apiKeyId, { onDelete: "cascade" }), actionId: text("actionId") .notNull() .references(() => actions.actionId, { onDelete: "cascade" }) }); export const apiKeyOrg = sqliteTable("apiKeyOrg", { apiKeyId: text("apiKeyId") .notNull() .references(() => apiKeys.apiKeyId, { onDelete: "cascade" }), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull() }); export const idpOrg = sqliteTable("idpOrg", { idpId: integer("idpId") .notNull() .references(() => idp.idpId, { onDelete: "cascade" }), orgId: text("orgId") .notNull() .references(() => orgs.orgId, { onDelete: "cascade" }), roleMapping: text("roleMapping"), orgMapping: text("orgMapping") }); // Blueprint runs export const blueprints = sqliteTable("blueprints", { blueprintId: integer("blueprintId").primaryKey({ autoIncrement: true }), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), name: text("name").notNull(), source: text("source").notNull(), createdAt: integer("createdAt").notNull(), succeeded: integer("succeeded", { mode: "boolean" }).notNull(), contents: text("contents").notNull(), message: text("message") }); export const requestAuditLog = sqliteTable( "requestAuditLog", { id: integer("id").primaryKey({ autoIncrement: true }), timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds orgId: text("orgId").references(() => orgs.orgId, { onDelete: "cascade" }), action: integer("action", { mode: "boolean" }).notNull(), reason: integer("reason").notNull(), actorType: text("actorType"), actor: text("actor"), actorId: text("actorId"), resourceId: integer("resourceId"), ip: text("ip"), location: text("location"), userAgent: text("userAgent"), metadata: text("metadata"), headers: text("headers"), // JSON blob query: text("query"), // JSON blob originalRequestURL: text("originalRequestURL"), scheme: text("scheme"), host: text("host"), path: text("path"), method: text("method"), tls: integer("tls", { mode: "boolean" }) }, (table) => [ index("idx_requestAuditLog_timestamp").on(table.timestamp), index("idx_requestAuditLog_org_timestamp").on( table.orgId, table.timestamp ) ] ); export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", { codeId: integer("codeId").primaryKey({ autoIncrement: true }), code: text("code").notNull().unique(), ip: text("ip"), city: text("city"), deviceName: text("deviceName"), applicationName: text("applicationName").notNull(), expiresAt: integer("expiresAt").notNull(), createdAt: integer("createdAt").notNull(), verified: integer("verified", { mode: "boolean" }).notNull().default(false), userId: text("userId").references(() => users.userId, { onDelete: "cascade" }) }); 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; export type User = InferSelectModel; export type Site = InferSelectModel; export type Resource = InferSelectModel; export type ExitNode = InferSelectModel; export type Target = InferSelectModel; export type Session = InferSelectModel; export type Newt = InferSelectModel; export type NewtSession = InferSelectModel; export type Olm = InferSelectModel; export type OlmSession = InferSelectModel; export type EmailVerificationCode = InferSelectModel< typeof emailVerificationCodes >; export type TwoFactorBackupCode = InferSelectModel; export type PasswordResetToken = InferSelectModel; export type Role = InferSelectModel; export type Action = InferSelectModel; export type RoleAction = InferSelectModel; export type UserAction = InferSelectModel; export type RoleSite = InferSelectModel; export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; export type UserInvite = InferSelectModel; export type UserOrg = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; export type ResourceHeaderAuth = InferSelectModel; export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel< typeof resourceHeaderAuthExtendedCompatibility >; export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; export type DnsRecord = InferSelectModel; export type Client = InferSelectModel; export type ClientSite = InferSelectModel; export type RoleClient = InferSelectModel; export type UserClient = InferSelectModel; export type SupporterKey = InferSelectModel; export type Idp = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; export type SiteResource = InferSelectModel; export type OrgDomains = InferSelectModel; export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; export type Blueprint = InferSelectModel; export type LicenseKey = InferSelectModel; export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; export type RequestAuditLog = InferSelectModel; export type DeviceWebAuthCode = InferSelectModel; export type RoundTripMessageTracker = InferSelectModel< typeof roundTripMessageTracker >; ================================================ FILE: server/emails/index.ts ================================================ export * from "@server/emails/sendEmail"; import nodemailer from "nodemailer"; import config from "@server/lib/config"; import logger from "@server/logger"; import SMTPTransport from "nodemailer/lib/smtp-transport"; function createEmailClient() { const emailConfig = config.getRawConfig().email; if (!emailConfig) { logger.warn( "Email SMTP configuration is missing. Emails will not be sent." ); return; } const settings = { host: emailConfig.smtp_host, port: emailConfig.smtp_port, secure: emailConfig.smtp_secure || false, auth: emailConfig.smtp_user && emailConfig.smtp_pass ? { user: emailConfig.smtp_user, pass: emailConfig.smtp_pass } : null } as SMTPTransport.Options; if (emailConfig.smtp_tls_reject_unauthorized !== undefined) { settings.tls = { rejectUnauthorized: emailConfig.smtp_tls_reject_unauthorized }; } return nodemailer.createTransport(settings); } export const emailClient = createEmailClient(); export default emailClient; ================================================ FILE: server/emails/sendEmail.ts ================================================ import { render } from "@react-email/render"; import { ReactElement } from "react"; import emailClient from "@server/emails"; import logger from "@server/logger"; export async function sendEmail( template: ReactElement, opts: { name?: string; from: string | undefined; to: string | undefined; subject: string; replyTo?: string; } ) { if (!emailClient) { logger.warn("Email client not configured, skipping email send"); return; } if (!opts.from || !opts.to || !opts.subject) { logger.error("Email missing required fields", opts); return; } const emailHtml = await render(template); const appName = process.env.BRANDING_APP_NAME || "Pangolin"; // From the private config loading into env vars to seperate away the private config await emailClient.sendMail({ from: { name: opts.name || appName, address: opts.from }, to: opts.to, replyTo: opts.replyTo, subject: opts.subject, html: emailHtml }); } export default sendEmail; ================================================ FILE: server/emails/templates/EnterpriseEditionKeyGenerated.tsx ================================================ import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, EmailFooter, EmailGreeting, EmailHeading, EmailInfoSection, EmailLetterHead, EmailSection, EmailSignature, EmailText } from "./components/Email"; import CopyCodeBox from "./components/CopyCodeBox"; import ButtonLink from "./components/ButtonLink"; type EnterpriseEditionKeyGeneratedProps = { keyValue: string; personalUseOnly: boolean; users: number; sites: number; modifySubscriptionLink?: string; }; export const EnterpriseEditionKeyGenerated = ({ keyValue, personalUseOnly, users, sites, modifySubscriptionLink }: EnterpriseEditionKeyGeneratedProps) => { const previewText = personalUseOnly ? "Your Enterprise Edition key for personal use is ready" : "Thank you for your purchase — your Enterprise Edition key is ready"; return ( {previewText} Hi there, {personalUseOnly ? ( Your Enterprise Edition license key has been generated. Qualifying users can use the Enterprise Edition for free for{" "} personal use only. ) : ( <> Thank you for your purchase. Your Enterprise Edition license key is ready. Below are the terms of your license. {modifySubscriptionLink && ( Modify subscription )} )} Your license key: If you need to purchase additional license keys or modify your existing license, please reach out to our support team at{" "} support@pangolin.net . ); }; export default EnterpriseEditionKeyGenerated; ================================================ FILE: server/emails/templates/NotifyResetPassword.tsx ================================================ import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, EmailFooter, EmailGreeting, EmailHeading, EmailLetterHead, EmailSignature, EmailText } from "./components/Email"; interface Props { email: string; } export const ConfirmPasswordReset = ({ email }: Props) => { const previewText = `Your password has been successfully reset.`; return ( {previewText} {/* Password Successfully Reset */} Hi there, Your password has been successfully reset. You can now sign in to your account using your new password. If you didn't make this change, please contact our support team immediately to secure your account. ); }; export default ConfirmPasswordReset; ================================================ FILE: server/emails/templates/NotifyUsageLimitApproaching.tsx ================================================ import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, EmailFooter, EmailGreeting, EmailHeading, EmailLetterHead, EmailSignature, EmailText } from "./components/Email"; interface Props { email: string; limitName: string; currentUsage: number; usageLimit: number; billingLink: string; // Link to billing page } export const NotifyUsageLimitApproaching = ({ email, limitName, currentUsage, usageLimit, billingLink }: Props) => { const previewText = `Your usage for ${limitName} is approaching the limit.`; const usagePercentage = Math.round((currentUsage / usageLimit) * 100); return ( {previewText} Usage Limit Warning Hi there, We wanted to let you know that your usage for{" "} {limitName} is approaching your plan limit. Current Usage: {currentUsage} of{" "} {usageLimit} ({usagePercentage}%) Once you reach your limit, some functionality may be restricted or your sites may disconnect until you upgrade your plan or your usage resets. To avoid any interruption to your service, we recommend upgrading your plan or monitoring your usage closely. You can{" "} upgrade your plan here. If you have any questions or need assistance, please don't hesitate to reach out to our support team. ); }; export default NotifyUsageLimitApproaching; ================================================ FILE: server/emails/templates/NotifyUsageLimitReached.tsx ================================================ import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, EmailFooter, EmailGreeting, EmailHeading, EmailLetterHead, EmailSignature, EmailText } from "./components/Email"; interface Props { email: string; limitName: string; currentUsage: number; usageLimit: number; billingLink: string; // Link to billing page } export const NotifyUsageLimitReached = ({ email, limitName, currentUsage, usageLimit, billingLink }: Props) => { const previewText = `You've reached your ${limitName} usage limit - Action required`; const usagePercentage = Math.round((currentUsage / usageLimit) * 100); return ( {previewText} Usage Limit Reached - Action Required Hi there, You have reached your usage limit for{" "} {limitName}. Current Usage: {currentUsage} of{" "} {usageLimit} ({usagePercentage}%) Important: Your functionality may now be restricted and your sites may disconnect until you either upgrade your plan or your usage resets. To prevent any service interruption, immediate action is recommended. What you can do:
•{" "} Upgrade your plan immediately {" "} to restore full functionality
• Monitor your usage to stay within limits in the future
If you have any questions or need immediate assistance, please contact our support team right away.
); }; export default NotifyUsageLimitReached; ================================================ FILE: server/emails/templates/ResetPasswordCode.tsx ================================================ import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, EmailFooter, EmailGreeting, EmailHeading, EmailLetterHead, EmailSection, EmailSignature, EmailText } from "./components/Email"; import CopyCodeBox from "./components/CopyCodeBox"; import ButtonLink from "./components/ButtonLink"; interface Props { email: string; code: string; link: string; } export const ResetPasswordCode = ({ email, code, link }: Props) => { const previewText = `Reset your password with code: ${code}`; return ( {previewText} {/* Reset Your Password */} Hi there, You've requested to reset your password. Click the button below to reset your password, or use the verification code provided if prompted. Reset Password This reset code will expire in 2 hours. If you didn't request a password reset, you can safely ignore this email. ); }; export default ResetPasswordCode; ================================================ FILE: server/emails/templates/ResourceOTPCode.tsx ================================================ import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { EmailContainer, EmailLetterHead, EmailHeading, EmailText, EmailFooter, EmailSection, EmailGreeting, EmailSignature } from "./components/Email"; import { themeColors } from "./lib/theme"; import CopyCodeBox from "./components/CopyCodeBox"; interface ResourceOTPCodeProps { email?: string; resourceName: string; orgName: string; otp: string; } export const ResourceOTPCode = ({ email, resourceName, orgName: organizationName, otp }: ResourceOTPCodeProps) => { const previewText = `Your access code for ${resourceName}: ${otp}`; return ( {previewText} {/* */} {/* Access Code for {resourceName} */} {/* */} Hi there, You've requested access to{" "} {resourceName} in{" "} {organizationName}. Use the verification code below to complete your authentication. This code will expire in 15 minutes. If you didn't request this code, please ignore this email. ); }; export default ResourceOTPCode; ================================================ FILE: server/emails/templates/SendInviteLink.tsx ================================================ import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, EmailFooter, EmailGreeting, EmailHeading, EmailLetterHead, EmailSection, EmailSignature, EmailText } from "./components/Email"; import ButtonLink from "./components/ButtonLink"; interface SendInviteLinkProps { email: string; inviteLink: string; orgName: string; inviterName?: string; expiresInDays: string; } export const SendInviteLink = ({ email, inviteLink, orgName, inviterName, expiresInDays }: SendInviteLinkProps) => { const previewText = `${inviterName} invited you to join ${orgName}`; return ( {previewText} {/* */} {/* You're Invited to Join {orgName} */} {/* */} Hi there, You've been invited to join{" "} {orgName} {inviterName ? ` by ${inviterName}` : ""}. Click the button below to accept your invitation and get started. Accept Invitation {/* */} {/* If you're having trouble clicking the button, copy */} {/* and paste the URL below into your web browser: */} {/*
*/} {/* {inviteLink} */} {/*
*/} This invite expires in {expiresInDays}{" "} {expiresInDays === "1" ? "day" : "days"}. If the link has expired, please contact the owner of the organization to request a new invitation.
); }; export default SendInviteLink; ================================================ FILE: server/emails/templates/SupportEmail.tsx ================================================ import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, EmailGreeting, EmailLetterHead, EmailText } from "./components/Email"; interface SupportEmailProps { email: string; username: string; subject: string; body: string; } export const SupportEmail = ({ username, email, body, subject }: SupportEmailProps) => { const previewText = subject; return ( {previewText} Hi support, You have received a new support request from{" "} {username} ({email}). Subject: {subject} Message: {body} ); }; export default SupportEmail; ================================================ FILE: server/emails/templates/TwoFactorAuthNotification.tsx ================================================ import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, EmailFooter, EmailGreeting, EmailHeading, EmailLetterHead, EmailSignature, EmailText } from "./components/Email"; interface Props { email: string; enabled: boolean; } export const TwoFactorAuthNotification = ({ email, enabled }: Props) => { const previewText = `Two-Factor Authentication ${enabled ? "enabled" : "disabled"} for your account`; return ( {previewText} {/* */} {/* Security Update: 2FA{" "} */} {/* {enabled ? "Enabled" : "Disabled"} */} {/* */} Hi there, Two-factor authentication has been successfully{" "} {enabled ? "enabled" : "disabled"}{" "} on your account. {enabled ? ( <> Your account is now protected with an additional layer of security. Keep your authentication method safe and accessible. ) : ( <> We recommend re-enabling two-factor authentication to keep your account secure. )} If you didn't make this change, please contact our support team immediately. ); }; export default TwoFactorAuthNotification; ================================================ FILE: server/emails/templates/VerifyEmailCode.tsx ================================================ import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, EmailFooter, EmailGreeting, EmailHeading, EmailLetterHead, EmailSection, EmailSignature, EmailText } from "./components/Email"; import CopyCodeBox from "./components/CopyCodeBox"; interface VerifyEmailProps { username?: string; verificationCode: string; verifyLink: string; } export const VerifyEmail = ({ username, verificationCode, verifyLink }: VerifyEmailProps) => { const previewText = `Verify your email with code: ${verificationCode}`; return ( {previewText} {/* Verify Your Email Address */} Hi there, Welcome! To complete your account setup, please verify your email address using the code below. This verification code will expire in 15 minutes. If you didn't create an account, you can safely ignore this email. ); }; export default VerifyEmail; ================================================ FILE: server/emails/templates/WelcomeQuickStart.tsx ================================================ import React from "react"; import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; import { themeColors } from "./lib/theme"; import { EmailContainer, EmailFooter, EmailGreeting, EmailHeading, EmailLetterHead, EmailSection, EmailSignature, EmailText, EmailInfoSection } from "./components/Email"; import ButtonLink from "./components/ButtonLink"; import CopyCodeBox from "./components/CopyCodeBox"; interface WelcomeQuickStartProps { username?: string; link: string; fallbackLink: string; resourceMethod: string; resourceHostname: string; resourcePort: string | number; resourceUrl: string; cliCommand: string; } export const WelcomeQuickStart = ({ username, link, fallbackLink, resourceMethod, resourceHostname, resourcePort, resourceUrl, cliCommand }: WelcomeQuickStartProps) => { const previewText = "Welcome! Here's what to do next"; return ( {previewText} Hi there, Thank you for trying out Pangolin! We're excited to have you on board. To continue to configure your site, resources, and other features, complete your account setup to access the full dashboard. View Your Dashboard {/*

*/} {/* If the button above doesn't work, you can also */} {/* use this{" "} */} {/* */} {/* link */} {/* */} {/* . */} {/*

*/}
Connect your site using Newt
{cliCommand}

To learn how to use Newt, including more installation methods, visit the{" "} docs .

{resourceUrl} ) } ]} />
); }; export default WelcomeQuickStart; ================================================ FILE: server/emails/templates/components/ButtonLink.tsx ================================================ import React from "react"; export default function ButtonLink({ href, children, className = "" }: { href: string; children: React.ReactNode; className?: string; }) { return ( {children} ); } ================================================ FILE: server/emails/templates/components/CopyCodeBox.tsx ================================================ import React from "react"; const DEFAULT_HINT = "Copy and paste this code when prompted"; export default function CopyCodeBox({ text, hint }: { text: string; hint?: string; }) { return (
{text}

{hint ?? DEFAULT_HINT}

); } ================================================ FILE: server/emails/templates/components/Email.tsx ================================================ import React from "react"; import { Container, Img } from "@react-email/components"; import { build } from "@server/build"; // EmailContainer: Wraps the entire email layout export function EmailContainer({ children }: { children: React.ReactNode }) { return ( {children} ); } // EmailLetterHead: For branding with logo on dark background export function EmailLetterHead() { return (
Pangolin Logo
); } // EmailHeading: For the primary message or headline export function EmailHeading({ children }: { children: React.ReactNode }) { return (

{children}

); } export function EmailGreeting({ children }: { children: React.ReactNode }) { return (

{children}

); } // EmailText: For general text content export function EmailText({ children, className }: { children: React.ReactNode; className?: string; }) { return (

{children}

); } // EmailSection: For visually distinct sections (like OTP) export function EmailSection({ children, className }: { children: React.ReactNode; className?: string; }) { return (
{children}
); } // EmailFooter: For closing or signature export function EmailFooter({ children }: { children: React.ReactNode }) { return ( <> {build === "saas" && (
{children}

For any questions or support, please contact us at:
support@pangolin.net

© {new Date().getFullYear()} Fossorial, Inc. All rights reserved.

)} ); } export function EmailSignature() { return (

Best regards,
The Pangolin Team

); } // EmailInfoSection: For structured key-value info (like resource details) export function EmailInfoSection({ title, items }: { title?: string; items: { label: string; value: React.ReactNode }[]; }) { return (
{title && (
{title}
)} {items.map((item, idx) => ( ))}
{item.label} {item.value}
); } ================================================ FILE: server/emails/templates/lib/theme.ts ================================================ import React from "react"; export const themeColors = { theme: { extend: { colors: { primary: "#F97317" } } } }; ================================================ FILE: server/extendZod.ts ================================================ import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi"; import { z } from "zod"; extendZodWithOpenApi(z); export default function extendZod() {} ================================================ FILE: server/index.ts ================================================ #! /usr/bin/env node import "./extendZod.ts"; import { runSetupFunctions } from "./setup"; import { createApiServer } from "./apiServer"; import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; import { createIntegrationApiServer } from "./integrationApiServer"; import { ApiKey, ApiKeyOrg, RemoteExitNode, Session, SiteResource, User, UserOrg } from "@server/db"; import config from "@server/lib/config"; import { setHostMeta } from "@server/lib/hostMeta"; import { initTelemetryClient } from "@server/lib/telemetry"; import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager"; import { initCleanup } from "#dynamic/cleanup"; import license from "#dynamic/license/license"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; import { fetchServerIp } from "@server/lib/serverIpService"; async function startServers() { await setHostMeta(); await config.initServer(); license.setServerSecret(config.getRawConfig().server.secret!); await license.check(); await runSetupFunctions(); await fetchServerIp(); initTelemetryClient(); initLogCleanupInterval(); // Start all servers const apiServer = createApiServer(); const internalServer = createInternalServer(); const nextServer = await createNextServer(); if (config.getRawConfig().traefik.file_mode) { const monitor = new TraefikConfigManager(); await monitor.start(); } let integrationServer; if (config.getRawConfig().flags?.enable_integration_api) { integrationServer = createIntegrationApiServer(); } await initCleanup(); return { apiServer, nextServer, internalServer, integrationServer }; } // Types declare global { namespace Express { interface Request { apiKey?: ApiKey; user?: User; session: Session; userOrg?: UserOrg; apiKeyOrg?: ApiKeyOrg; userOrgRoleId?: number; userOrgId?: string; userOrgIds?: string[]; remoteExitNode?: RemoteExitNode; siteResource?: SiteResource; orgPolicyAllowed?: boolean; } } } startServers().catch(console.error); ================================================ FILE: server/integrationApiServer.ts ================================================ import express from "express"; import cors from "cors"; import cookieParser from "cookie-parser"; import config from "@server/lib/config"; import logger from "@server/logger"; import { errorHandlerMiddleware, notFoundMiddleware } from "@server/middlewares"; import { authenticated, unauthenticated } from "#dynamic/routers/integration"; import { logIncomingMiddleware } from "./middlewares/logIncoming"; import helmet from "helmet"; import swaggerUi from "swagger-ui-express"; import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; import { registry } from "./openApi"; import fs from "fs"; import path from "path"; import { APP_PATH } from "./lib/consts"; import yaml from "js-yaml"; import { z } from "zod"; const dev = process.env.ENVIRONMENT !== "prod"; const externalPort = config.getRawConfig().server.integration_port; export function createIntegrationApiServer() { const apiServer = express(); const trustProxy = config.getRawConfig().server.trust_proxy; if (trustProxy) { apiServer.set("trust proxy", trustProxy); } apiServer.use(cors()); if (!dev) { apiServer.use(helmet()); } apiServer.use(cookieParser()); apiServer.use(express.json()); const openApiDocumentation = getOpenApiDocumentation(); apiServer.use( "/v1/docs", swaggerUi.serve, swaggerUi.setup(openApiDocumentation) ); // Unauthenticated OpenAPI spec endpoints apiServer.get("/v1/openapi.json", (_req, res) => { res.json(openApiDocumentation); }); apiServer.get("/v1/openapi.yaml", (_req, res) => { const yamlOutput = yaml.dump(openApiDocumentation); res.type("application/yaml").send(yamlOutput); }); // API routes const prefix = `/v1`; apiServer.use(logIncomingMiddleware); apiServer.use(prefix, unauthenticated); apiServer.use(prefix, authenticated); // Error handling apiServer.use(notFoundMiddleware); apiServer.use(errorHandlerMiddleware); // Create HTTP server const httpServer = apiServer.listen(externalPort, (err?: any) => { if (err) throw err; logger.info( `Integration API server is running on http://localhost:${externalPort}` ); }); return httpServer; } function getOpenApiDocumentation() { const bearerAuth = registry.registerComponent( "securitySchemes", "Bearer Auth", { type: "http", scheme: "bearer" } ); registry.registerPath({ method: "get", path: "/", description: "Health check", tags: [], request: {}, responses: {} }); registry.registerPath({ method: "get", path: "/openapi.json", description: "Get OpenAPI specification as JSON", tags: [], request: {}, responses: { "200": { description: "OpenAPI specification as JSON", content: { "application/json": { schema: { type: "object" } } } } } }); registry.registerPath({ method: "get", path: "/openapi.yaml", description: "Get OpenAPI specification as YAML", tags: [], request: {}, responses: { "200": { description: "OpenAPI specification as YAML", content: { "application/yaml": { schema: { type: "string" } } } } } }); for (const def of registry.definitions) { if (def.type === "route") { def.route.security = [ { [bearerAuth.name]: [] } ]; // Ensure every route has a generic JSON response schema so Swagger UI can render responses const existingResponses = def.route.responses; const hasExistingResponses = existingResponses && Object.keys(existingResponses).length > 0; if (!hasExistingResponses) { def.route.responses = { "*": { description: "", content: { "application/json": { schema: z.object({}) } } } }; } } } const generator = new OpenApiGeneratorV3(registry.definitions); const generated = generator.generateDocument({ openapi: "3.0.0", info: { version: "v1", title: "Pangolin Integration API" }, servers: [{ url: "/v1" }] }); if (!process.env.DISABLE_GEN_OPENAPI) { // convert to yaml and save to file const outputPath = path.join(APP_PATH, "openapi.yaml"); const yamlOutput = yaml.dump(generated); fs.writeFileSync(outputPath, yamlOutput, "utf8"); logger.info(`OpenAPI documentation saved to ${outputPath}`); } return generated; } ================================================ FILE: server/internalServer.ts ================================================ import express from "express"; import helmet from "helmet"; import cors from "cors"; import cookieParser from "cookie-parser"; import config from "@server/lib/config"; import logger from "@server/logger"; import { errorHandlerMiddleware, notFoundMiddleware } from "@server/middlewares"; import { internalRouter } from "#dynamic/routers/internal"; import { stripDuplicateSesions } from "./middlewares/stripDuplicateSessions"; const internalPort = config.getRawConfig().server.internal_port; export function createInternalServer() { const internalServer = express(); const trustProxy = config.getRawConfig().server.trust_proxy; if (trustProxy) { internalServer.set("trust proxy", trustProxy); } internalServer.use(helmet()); internalServer.use(cors()); internalServer.use(stripDuplicateSesions); internalServer.use(cookieParser()); internalServer.use(express.json()); const prefix = `/api/v1`; internalServer.use(prefix, internalRouter); internalServer.use(notFoundMiddleware); internalServer.use(errorHandlerMiddleware); internalServer.listen(internalPort, (err?: any) => { if (err) throw err; logger.info( `Internal server is running on http://localhost:${internalPort}` ); }); return internalServer; } ================================================ FILE: server/lib/asn.ts ================================================ import logger from "@server/logger"; import { maxmindAsnLookup } from "@server/db/maxmindAsn"; export async function getAsnForIp(ip: string): Promise { try { if (!maxmindAsnLookup) { logger.debug( "MaxMind ASN DB path not configured, cannot perform ASN lookup" ); return; } const result = maxmindAsnLookup.get(ip); if (!result || !result.autonomous_system_number) { return; } logger.debug( `ASN lookup successful for IP ${ip}: AS${result.autonomous_system_number}` ); return result.autonomous_system_number; } catch (error) { logger.error("Error performing ASN lookup:", error); } return; } ================================================ FILE: server/lib/billing/createCustomer.ts ================================================ export async function createCustomer( orgId: string, email: string | null | undefined ): Promise { return; } ================================================ FILE: server/lib/billing/features.ts ================================================ export enum FeatureId { USERS = "users", SITES = "sites", EGRESS_DATA_MB = "egressDataMb", DOMAINS = "domains", REMOTE_EXIT_NODES = "remoteExitNodes", ORGINIZATIONS = "organizations", TIER1 = "tier1" } export async function getFeatureDisplayName(featureId: FeatureId): Promise { switch (featureId) { case FeatureId.USERS: return "Users"; case FeatureId.SITES: return "Sites"; case FeatureId.EGRESS_DATA_MB: return "Egress Data (MB)"; case FeatureId.DOMAINS: return "Domains"; case FeatureId.REMOTE_EXIT_NODES: return "Remote Exit Nodes"; case FeatureId.ORGINIZATIONS: return "Organizations"; case FeatureId.TIER1: return "Home Lab"; default: return featureId; } } // this is from the old system export const FeatureMeterIds: Partial> = { // right now we are not charging for any data // [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" }; export const FeatureMeterIdsSandbox: Partial> = { // [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ" }; export function getFeatureMeterId(featureId: FeatureId): string | undefined { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" ) { return FeatureMeterIds[featureId]; } else { return FeatureMeterIdsSandbox[featureId]; } } export function getFeatureIdByMetricId( metricId: string ): FeatureId | undefined { return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find( ([_, v]) => v === metricId )?.[0]; } export type FeaturePriceSet = Partial>; export const tier1FeaturePriceSet: FeaturePriceSet = { [FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G" }; export const tier1FeaturePriceSetSandbox: FeaturePriceSet = { [FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" }; export function getTier1FeaturePriceSet(): FeaturePriceSet { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" ) { return tier1FeaturePriceSet; } else { return tier1FeaturePriceSetSandbox; } } export const tier2FeaturePriceSet: FeaturePriceSet = { [FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN" }; export const tier2FeaturePriceSetSandbox: FeaturePriceSet = { [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" }; export function getTier2FeaturePriceSet(): FeaturePriceSet { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" ) { return tier2FeaturePriceSet; } else { return tier2FeaturePriceSetSandbox; } } export const tier3FeaturePriceSet: FeaturePriceSet = { [FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv" }; export const tier3FeaturePriceSetSandbox: FeaturePriceSet = { [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" }; export function getTier3FeaturePriceSet(): FeaturePriceSet { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" ) { return tier3FeaturePriceSet; } else { return tier3FeaturePriceSetSandbox; } } export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined { // Check all feature price sets const allPriceSets = [ getTier1FeaturePriceSet(), getTier2FeaturePriceSet(), getTier3FeaturePriceSet() ]; for (const priceSet of allPriceSets) { const entry = (Object.entries(priceSet) as [FeatureId, string][]).find( ([_, price]) => price === priceId ); if (entry) { return entry[0]; } } return undefined; } ================================================ FILE: server/lib/billing/getLineItems.ts ================================================ import Stripe from "stripe"; import { FeatureId, FeaturePriceSet } from "./features"; import { usageService } from "./usageService"; export async function getLineItems( featurePriceSet: FeaturePriceSet, orgId: string, ): Promise { const users = await usageService.getUsage(orgId, FeatureId.USERS); return Object.entries(featurePriceSet).map(([featureId, priceId]) => { let quantity: number | undefined; if (featureId === FeatureId.USERS) { quantity = users?.instantaneousValue || 1; } else if (featureId === FeatureId.TIER1) { quantity = 1; } return { price: priceId, quantity: quantity }; }); } ================================================ FILE: server/lib/billing/getOrgTierData.ts ================================================ export async function getOrgTierData( orgId: string ): Promise<{ tier: string | null; active: boolean }> { const tier = null; const active = false; return { tier, active }; } ================================================ FILE: server/lib/billing/index.ts ================================================ export * from "./limitSet"; export * from "./features"; export * from "./limitsService"; export * from "./getOrgTierData"; export * from "./createCustomer"; ================================================ FILE: server/lib/billing/licenses.ts ================================================ export enum LicenseId { SMALL_LICENSE = "small_license", BIG_LICENSE = "big_license" } export type LicensePriceSet = { [key in LicenseId]: string; }; export const licensePriceSet: LicensePriceSet = { // Free license matches the freeLimitSet [LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8", [LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y" }; export const licensePriceSetSandbox: LicensePriceSet = { // Free license matches the freeLimitSet // when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value [LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN", [LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl" }; export function getLicensePriceSet( environment?: string, sandbox_mode?: boolean ): LicensePriceSet { if ( (process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true") || (environment === "prod" && sandbox_mode !== true) ) { // THIS GETS LOADED CLIENT SIDE AND SERVER SIDE return licensePriceSet; } else { return licensePriceSetSandbox; } } ================================================ FILE: server/lib/billing/limitSet.ts ================================================ import { FeatureId } from "./features"; export type LimitSet = Partial<{ [key in FeatureId]: { value: number | null; // null indicates no limit description?: string; }; }>; 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 = { [FeatureId.USERS]: { value: 7, description: "Home limit" }, [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 = { [FeatureId.USERS]: { value: 100, description: "Team limit" }, [FeatureId.SITES]: { value: 50, description: "Team limit" }, [FeatureId.DOMAINS]: { value: 50, description: "Team limit" }, [FeatureId.REMOTE_EXIT_NODES]: { value: 3, description: "Team limit" }, [FeatureId.ORGINIZATIONS]: { value: 1, description: "Team limit" } }; export const tier3LimitSet: LimitSet = { [FeatureId.USERS]: { value: 500, description: "Business limit" }, [FeatureId.SITES]: { value: 250, description: "Business limit" }, [FeatureId.DOMAINS]: { value: 100, description: "Business limit" }, [FeatureId.REMOTE_EXIT_NODES]: { value: 20, description: "Business limit" }, [FeatureId.ORGINIZATIONS]: { value: 5, description: "Business limit" }, }; ================================================ FILE: server/lib/billing/limitsService.ts ================================================ import { db, limits } from "@server/db"; import { and, eq } from "drizzle-orm"; import { LimitSet } from "./limitSet"; import { FeatureId } from "./features"; import logger from "@server/logger"; class LimitService { async applyLimitSetToOrg(orgId: string, limitSet: LimitSet): Promise { const limitEntries = Object.entries(limitSet); // delete existing limits for the org await db.transaction(async (trx) => { await trx.delete(limits).where(eq(limits.orgId, orgId)); for (const [featureId, entry] of limitEntries) { const limitId = `${orgId}-${featureId}`; const { value, description } = entry; // get the limit first const [limit] = await trx .select() .from(limits) .where(eq(limits.limitId, limitId)) .limit(1); // check if its overriden if (limit && limit.override) { logger.debug( `Skipping limit ${limitId} for org ${orgId} since it is overridden...` ); continue; } await trx .insert(limits) .values({ limitId, orgId, featureId, value, description }); } }); } async getOrgLimit( orgId: string, featureId: FeatureId ): Promise { const limitId = `${orgId}-${featureId}`; const [limit] = await db .select() .from(limits) .where(and(eq(limits.limitId, limitId))) .limit(1); return limit ? limit.value : null; } } export const limitsService = new LimitService(); ================================================ FILE: server/lib/billing/tierMatrix.ts ================================================ import { Tier } from "@server/types/Tiers"; export enum TierFeature { OrgOidc = "orgOidc", LoginPageDomain = "loginPageDomain", // handle downgrade by removing custom domain DeviceApprovals = "deviceApprovals", // handle downgrade by disabling device approvals LoginPageBranding = "loginPageBranding", // handle downgrade by setting to default branding LogExport = "logExport", AccessLogs = "accessLogs", // set the retention period to none on downgrade ActionLogs = "actionLogs", // set the retention period to none on downgrade RotateCredentials = "rotateCredentials", MaintencePage = "maintencePage", // handle downgrade DevicePosture = "devicePosture", 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 SshPam = "sshPam" } export const tierMatrix: Record = { [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"], [TierFeature.LoginPageBranding]: ["tier1", "tier3", "enterprise"], [TierFeature.LogExport]: ["tier3", "enterprise"], [TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"], [TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"], [TierFeature.TwoFactorEnforcement]: [ "tier1", "tier2", "tier3", "enterprise" ], [TierFeature.SessionDurationPolicies]: [ "tier1", "tier2", "tier3", "enterprise" ], [TierFeature.PasswordExpirationPolicies]: [ "tier1", "tier2", "tier3", "enterprise" ], [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"] }; ================================================ FILE: server/lib/billing/usageService.ts ================================================ import { eq, sql, and } from "drizzle-orm"; import { db, usage, customers, limits, Usage, Limit, Transaction, orgs } from "@server/db"; import { FeatureId, getFeatureMeterId } from "./features"; import logger from "@server/logger"; import { build } from "@server/build"; import cache from "#dynamic/lib/cache"; export function noop() { if (build !== "saas") { return true; } return false; } export class UsageService { constructor() { if (noop()) { return; } } /** * Truncate a number to 11 decimal places to prevent precision issues */ private truncateValue(value: number): number { return Math.round(value * 100000000000) / 100000000000; // 11 decimal places } public async add( orgId: string, featureId: FeatureId, value: number, transaction: any = null ): Promise { if (noop()) { return null; } // Truncate value to 11 decimal places value = this.truncateValue(value); // Implement retry logic for deadlock handling const maxRetries = 3; let attempt = 0; while (attempt <= maxRetries) { try { let usage; if (transaction) { const orgIdToUse = await this.getBillingOrg(orgId, transaction); usage = await this.internalAddUsage( orgIdToUse, featureId, value, transaction ); } else { await db.transaction(async (trx) => { const orgIdToUse = await this.getBillingOrg(orgId, trx); usage = await this.internalAddUsage( orgIdToUse, featureId, value, trx ); }); } return usage || null; } catch (error: any) { // Check if this is a deadlock error const isDeadlock = error?.code === "40P01" || error?.cause?.code === "40P01" || (error?.message && error.message.includes("deadlock")); if (isDeadlock && attempt < maxRetries) { attempt++; // Exponential backoff with jitter: 50-150ms, 100-300ms, 200-600ms const baseDelay = Math.pow(2, attempt - 1) * 50; const jitter = Math.random() * baseDelay; const delay = baseDelay + jitter; logger.warn( `Deadlock detected for ${orgId}/${featureId}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms` ); await new Promise((resolve) => setTimeout(resolve, delay)); continue; } logger.error( `Failed to add usage for ${orgId}/${featureId} after ${attempt} attempts:`, error ); break; } } return null; } private async internalAddUsage( orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount featureId: FeatureId, value: number, trx: Transaction ): Promise { // Truncate value to 11 decimal places value = this.truncateValue(value); const usageId = `${orgId}-${featureId}`; const meterId = getFeatureMeterId(featureId); // Use upsert: insert if not exists, otherwise increment const [returnUsage] = await trx .insert(usage) .values({ usageId, featureId, orgId, meterId, instantaneousValue: value || 0, latestValue: value || 0, updatedAt: Math.floor(Date.now() / 1000) }) .onConflictDoUpdate({ target: usage.usageId, set: { instantaneousValue: sql`COALESCE(${usage.instantaneousValue}, 0) + ${value}` } }) .returning(); logger.debug( `Added usage for org ${orgId} feature ${featureId}: +${value}, new instantaneousValue: ${returnUsage.instantaneousValue}` ); return returnUsage; } // Helper function to get today's date as string (YYYY-MM-DD) getTodayDateString(): string { return new Date().toISOString().split("T")[0]; } // Helper function to get date string from Date object getDateString(date: number): string { return new Date(date * 1000).toISOString().split("T")[0]; } async updateCount( orgId: string, featureId: FeatureId, value?: number, customerId?: string ): Promise { if (noop()) { 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); } let currentUsage: Usage | null = null; await db.transaction(async (trx) => { // Get existing meter record const usageId = `${orgIdToUse}-${featureId}`; // Get current usage record [currentUsage] = await trx .select() .from(usage) .where(eq(usage.usageId, usageId)) .limit(1); if (currentUsage) { await trx .update(usage) .set({ instantaneousValue: value, updatedAt: Math.floor(Date.now() / 1000) }) .where(eq(usage.usageId, usageId)); } else { // First record for this meter const meterId = getFeatureMeterId(featureId); await trx.insert(usage).values({ usageId, featureId, orgId: orgIdToUse, meterId, instantaneousValue: value || 0, latestValue: value || 0, updatedAt: Math.floor(Date.now() / 1000) }); } }); // if (privateConfig.getRawPrivateConfig().flags.usage_reporting) { // await this.logStripeEvent(featureId, value || 0, customerId); // } } catch (error) { logger.error( `Failed to update count usage for ${orgIdToUse}/${featureId}:`, error ); } } private async getCustomerId( orgId: string, featureId: FeatureId ): Promise { const orgIdToUse = await this.getBillingOrg(orgId); const cacheKey = `customer_${orgIdToUse}_${featureId}`; const cached = await cache.get(cacheKey); if (cached) { return cached; } try { // Query subscription data const [customer] = await db .select({ customerId: customers.customerId }) .from(customers) .where(eq(customers.orgId, orgIdToUse)) .limit(1); if (!customer) { return null; } const customerId = customer.customerId; // Cache the result await cache.set(cacheKey, customerId, 300); // 5 minute TTL return customerId; } catch (error) { logger.error( `Failed to get subscription data for ${orgIdToUse}/${featureId}:`, error ); return null; } } public async getUsage( orgId: string, featureId: FeatureId, trx: Transaction | typeof db = db ): Promise { if (noop()) { return null; } const orgIdToUse = await this.getBillingOrg(orgId, trx); const usageId = `${orgIdToUse}-${featureId}`; try { const [result] = await trx .select() .from(usage) .where(eq(usage.usageId, usageId)) .limit(1); if (!result) { // Lets create one if it doesn't exist using upsert to handle race conditions logger.info( `Creating new usage record for ${orgIdToUse}/${featureId}` ); const meterId = getFeatureMeterId(featureId); try { const [newUsage] = await trx .insert(usage) .values({ usageId, featureId, orgId: orgIdToUse, meterId, latestValue: 0, updatedAt: Math.floor(Date.now() / 1000) }) .onConflictDoNothing() .returning(); if (newUsage) { return newUsage; } else { // Record was created by another process, fetch it const [existingUsage] = await trx .select() .from(usage) .where(eq(usage.usageId, usageId)) .limit(1); return existingUsage || null; } } catch (insertError) { // Fallback: try to fetch existing record in case of any insert issues logger.warn( `Insert failed for ${orgIdToUse}/${featureId}, attempting to fetch existing record:`, insertError ); const [existingUsage] = await trx .select() .from(usage) .where(eq(usage.usageId, usageId)) .limit(1); return existingUsage || null; } } return result; } catch (error) { logger.error( `Failed to get usage for ${orgIdToUse}/${featureId}:`, error ); throw error; } } public async getBillingOrg( orgId: string, trx: Transaction | typeof db = db ): Promise { 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( orgId: string, featureId?: FeatureId, usage?: Usage, trx: Transaction | typeof db = db ): Promise { 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; try { let orgLimits: Limit[] = []; if (featureId) { // Get all limits set for this organization orgLimits = await trx .select() .from(limits) .where( and( eq(limits.orgId, orgIdToUse), eq(limits.featureId, featureId) ) ); } else { // Get all limits set for this organization orgLimits = await trx .select() .from(limits) .where(eq(limits.orgId, orgIdToUse)); } if (orgLimits.length === 0) { logger.debug(`No limits set for org ${orgIdToUse}`); return false; } // Check each limit against current usage for (const limit of orgLimits) { let currentUsage: Usage | null; if (usage) { currentUsage = usage; } else { currentUsage = await this.getUsage( orgIdToUse, limit.featureId as FeatureId, trx ); } const usageValue = currentUsage?.instantaneousValue || currentUsage?.latestValue || 0; logger.debug( `Current usage for org ${orgIdToUse} on feature ${limit.featureId}: ${usageValue}` ); logger.debug( `Limit for org ${orgIdToUse} on feature ${limit.featureId}: ${limit.value}` ); if ( currentUsage && limit.value !== null && usageValue > limit.value ) { logger.debug( `Org ${orgIdToUse} has exceeded limit for ${limit.featureId}: ` + `${usageValue} > ${limit.value}` ); hasExceededLimits = true; break; // Exit early if any limit is exceeded } } } catch (error) { logger.error(`Error checking limits for org ${orgIdToUse}:`, error); } return hasExceededLimits; } } export const usageService = new UsageService(); ================================================ FILE: server/lib/blueprints/MaintenanceSchema.ts ================================================ import { z } from "zod"; export const MaintenanceSchema = z.object({}); ================================================ FILE: server/lib/blueprints/applyBlueprint.ts ================================================ import { db, newts, blueprints, Blueprint, Site, siteResources, roleSiteResources, userSiteResources, clientSiteResources } from "@server/db"; import { Config, ConfigSchema } from "./types"; import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { sites } from "@server/db"; import { eq, and, isNotNull } from "drizzle-orm"; import { addTargets as addProxyTargets } from "@server/routers/newt/targets"; import { addTargets as addClientTargets } from "@server/routers/client/targets"; import { ClientResourcesResults, updateClientResources } from "./clientResources"; import { BlueprintSource } from "@server/routers/blueprints/types"; import { stringify as stringifyYaml } from "yaml"; import { faker } from "@faker-js/faker"; import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource"; import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations"; type ApplyBlueprintArgs = { orgId: string; configData: unknown; name?: string; siteId?: number; source?: BlueprintSource; }; export async function applyBlueprint({ orgId, configData, siteId, name, source = "API" }: ApplyBlueprintArgs): Promise { // Validate the input data const validationResult = ConfigSchema.safeParse(configData); if (!validationResult.success) { throw new Error(fromError(validationResult.error).toString()); } const config: Config = validationResult.data; let blueprintSucceeded: boolean = false; let blueprintMessage: string; let error: any | null = null; try { let proxyResourcesResults: ProxyResourcesResults = []; let clientResourcesResults: ClientResourcesResults = []; await db.transaction(async (trx) => { proxyResourcesResults = await updateProxyResources( orgId, config, trx, siteId ); clientResourcesResults = await updateClientResources( orgId, config, trx, siteId ); logger.debug( `Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}` ); // We need to update the targets on the newts from the successfully updated information for (const result of proxyResourcesResults) { for (const target of result.targetsToUpdate) { const [site] = await trx .select() .from(sites) .innerJoin(newts, eq(sites.siteId, newts.siteId)) .where( and( eq(sites.siteId, target.siteId), eq(sites.orgId, orgId), eq(sites.type, "newt"), isNotNull(sites.pubKey) ) ) .limit(1); if (site) { logger.debug( `Updating target ${target.targetId} on site ${site.sites.siteId}` ); // see if you can find a matching target health check from the healthchecksToUpdate array const matchingHealthcheck = result.healthchecksToUpdate.find( (hc) => hc.targetId === target.targetId ); await addProxyTargets( site.newt.newtId, [target], matchingHealthcheck ? [matchingHealthcheck] : [], result.proxyResource.protocol, site.newt.version ); } } } logger.debug( `Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}` ); // We need to update the targets on the newts from the successfully updated information for (const result of clientResourcesResults) { if ( result.oldSiteResource && result.oldSiteResource.siteId != result.newSiteResource.siteId ) { // query existing associations const existingRoleIds = await trx .select() .from(roleSiteResources) .where( eq( roleSiteResources.siteResourceId, result.oldSiteResource.siteResourceId ) ) .then((rows) => rows.map((row) => row.roleId)); const existingUserIds = await trx .select() .from(userSiteResources) .where( eq( userSiteResources.siteResourceId, result.oldSiteResource.siteResourceId ) ) .then((rows) => rows.map((row) => row.userId)); const existingClientIds = await trx .select() .from(clientSiteResources) .where( eq( clientSiteResources.siteResourceId, result.oldSiteResource.siteResourceId ) ) .then((rows) => rows.map((row) => row.clientId)); // delete the existing site resource await trx .delete(siteResources) .where( and( eq( siteResources.siteResourceId, result.oldSiteResource.siteResourceId ) ) ); await rebuildClientAssociationsFromSiteResource( result.oldSiteResource, trx ); const [insertedSiteResource] = await trx .insert(siteResources) .values({ ...result.newSiteResource }) .returning(); // wait some time to allow for messages to be handled await new Promise((resolve) => setTimeout(resolve, 750)); //////////////////// update the associations //////////////////// if (existingRoleIds.length > 0) { await trx.insert(roleSiteResources).values( existingRoleIds.map((roleId) => ({ roleId, siteResourceId: insertedSiteResource!.siteResourceId })) ); } if (existingUserIds.length > 0) { await trx.insert(userSiteResources).values( existingUserIds.map((userId) => ({ userId, siteResourceId: insertedSiteResource!.siteResourceId })) ); } if (existingClientIds.length > 0) { await trx.insert(clientSiteResources).values( existingClientIds.map((clientId) => ({ clientId, siteResourceId: insertedSiteResource!.siteResourceId })) ); } await rebuildClientAssociationsFromSiteResource( insertedSiteResource, trx ); } else { const [newSite] = await trx .select() .from(sites) .innerJoin(newts, eq(sites.siteId, newts.siteId)) .where( and( eq(sites.siteId, result.newSiteResource.siteId), eq(sites.orgId, orgId), eq(sites.type, "newt"), isNotNull(sites.pubKey) ) ) .limit(1); if (!newSite) { logger.debug( `No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` ); continue; } logger.debug( `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}` ); await handleMessagingForUpdatedSiteResource( result.oldSiteResource, result.newSiteResource, { siteId: newSite.sites.siteId, orgId: newSite.sites.orgId }, trx ); } // await addClientTargets( // site.newt.newtId, // result.resource.destination, // result.resource.destinationPort, // result.resource.protocol, // result.resource.proxyPort // ); } }); blueprintSucceeded = true; blueprintMessage = "Blueprint applied successfully"; } catch (err) { blueprintSucceeded = false; blueprintMessage = `Blueprint applied with errors: ${err}`; logger.error(blueprintMessage); error = err; } let blueprint: Blueprint | null = null; await db.transaction(async (trx) => { const newBlueprint = await trx .insert(blueprints) .values({ orgId, name: name ?? `${faker.word.adjective()} ${faker.word.adjective()} ${faker.word.noun()}`, contents: stringifyYaml(configData), createdAt: Math.floor(Date.now() / 1000), succeeded: blueprintSucceeded, message: blueprintMessage, source }) .returning(); blueprint = newBlueprint[0]; }); if (!blueprint || (source !== "UI" && !blueprintSucceeded)) { // ^^^^^^^^^^^^^^^ The UI considers a failed blueprint as a valid response throw error ?? "Unknown Server Error"; } return blueprint; } ================================================ FILE: server/lib/blueprints/applyNewtDockerBlueprint.ts ================================================ import { sendToClient } from "#dynamic/routers/ws"; import { processContainerLabels } from "./parseDockerContainers"; import { applyBlueprint } from "./applyBlueprint"; import { db, sites } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; export async function applyNewtDockerBlueprint( siteId: number, newtId: string, containers: any ) { const [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { logger.warn("Site not found in applyNewtDockerBlueprint"); return; } // logger.debug(`Applying Docker blueprint to site: ${siteId}`); // logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`); try { const blueprint = processContainerLabels(containers); logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`); // make sure this is not an empty object if (isEmptyObject(blueprint)) { return; } if ( isEmptyObject(blueprint["proxy-resources"]) && isEmptyObject(blueprint["client-resources"]) && isEmptyObject(blueprint["public-resources"]) && isEmptyObject(blueprint["private-resources"]) ) { return; } // Update the blueprint in the database await applyBlueprint({ orgId: site.orgId, configData: blueprint, siteId: site.siteId, source: "NEWT" }); } catch (error) { logger.error(`Failed to update database from config: ${error}`); await sendToClient(newtId, { type: "newt/blueprint/results", data: { success: false, message: `Failed to apply blueprint from config: ${error}` } }); return; } await sendToClient(newtId, { type: "newt/blueprint/results", data: { success: true, message: "Config updated successfully" } }); } function isEmptyObject(obj: any) { if (obj === null || obj === undefined) { return true; } return Object.keys(obj).length === 0 && obj.constructor === Object; } ================================================ FILE: server/lib/blueprints/clientResources.ts ================================================ import { clients, clientSiteResources, roles, roleSiteResources, SiteResource, siteResources, Transaction, userOrgs, users, userSiteResources } from "@server/db"; import { sites } from "@server/db"; import { eq, and, ne, inArray, or } from "drizzle-orm"; import { Config } from "./types"; import logger from "@server/logger"; import { getNextAvailableAliasAddress } from "../ip"; export type ClientResourcesResults = { newSiteResource: SiteResource; oldSiteResource?: SiteResource; }[]; export async function updateClientResources( orgId: string, config: Config, trx: Transaction, siteId?: number ): Promise { const results: ClientResourcesResults = []; for (const [resourceNiceId, resourceData] of Object.entries( config["client-resources"] )) { const [existingResource] = await trx .select() .from(siteResources) .where( and( eq(siteResources.orgId, orgId), eq(siteResources.niceId, resourceNiceId) ) ) .limit(1); const resourceSiteId = resourceData.site; let site; if (resourceSiteId) { // Look up site by niceId [site] = await trx .select({ siteId: sites.siteId }) .from(sites) .where( and( eq(sites.niceId, resourceSiteId), eq(sites.orgId, orgId) ) ) .limit(1); } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx .select({ siteId: sites.siteId }) .from(sites) .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .limit(1); } else { throw new Error(`Target site is required`); } if (!site) { throw new Error( `Site not found: ${resourceSiteId} in org ${orgId}` ); } if (existingResource) { // Update existing resource const [updatedResource] = await trx .update(siteResources) .set({ name: resourceData.name || resourceNiceId, siteId: site.siteId, mode: resourceData.mode, destination: resourceData.destination, enabled: true, // hardcoded for now // enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, disableIcmp: resourceData["disable-icmp"], tcpPortRangeString: resourceData["tcp-ports"], udpPortRangeString: resourceData["udp-ports"] }) .where( eq( siteResources.siteResourceId, existingResource.siteResourceId ) ) .returning(); const siteResourceId = existingResource.siteResourceId; const orgId = existingResource.orgId; await trx .delete(clientSiteResources) .where(eq(clientSiteResources.siteResourceId, siteResourceId)); if (resourceData.machines.length > 0) { // get clientIds from niceIds const clientsToUpdate = await trx .select() .from(clients) .where( and( inArray(clients.niceId, resourceData.machines), eq(clients.orgId, orgId) ) ); const clientIds = clientsToUpdate.map( (client) => client.clientId ); await trx.insert(clientSiteResources).values( clientIds.map((clientId) => ({ clientId, siteResourceId })) ); } await trx .delete(userSiteResources) .where(eq(userSiteResources.siteResourceId, siteResourceId)); if (resourceData.users.length > 0) { // get userIds from username const usersToUpdate = await trx .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where( and( or( inArray(users.username, resourceData.users), inArray(users.email, resourceData.users) ), eq(userOrgs.orgId, orgId) ) ); const userIds = usersToUpdate.map((user) => user.user.userId); await trx .insert(userSiteResources) .values( userIds.map((userId) => ({ userId, siteResourceId })) ); } // Get all admin role IDs for this org to exclude from deletion const adminRoles = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))); const adminRoleIds = adminRoles.map((role) => role.roleId); if (adminRoleIds.length > 0) { await trx.delete(roleSiteResources).where( and( eq(roleSiteResources.siteResourceId, siteResourceId), ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role ) ); } else { await trx .delete(roleSiteResources) .where( eq(roleSiteResources.siteResourceId, siteResourceId) ); } if (resourceData.roles.length > 0) { // Re-add specified roles but we need to get the roleIds from the role name in the array const rolesToUpdate = await trx .select() .from(roles) .where( and( eq(roles.orgId, orgId), inArray(roles.name, resourceData.roles) ) ); const roleIds = rolesToUpdate.map((role) => role.roleId); await trx .insert(roleSiteResources) .values( roleIds.map((roleId) => ({ roleId, siteResourceId })) ); } results.push({ newSiteResource: updatedResource, oldSiteResource: existingResource }); } else { let aliasAddress: string | null = null; if (resourceData.mode == "host") { // we can only have an alias on a host aliasAddress = await getNextAvailableAliasAddress(orgId); } // Create new resource const [newResource] = await trx .insert(siteResources) .values({ orgId: orgId, siteId: site.siteId, niceId: resourceNiceId, name: resourceData.name || resourceNiceId, mode: resourceData.mode, destination: resourceData.destination, enabled: true, // hardcoded for now // enabled: resourceData.enabled ?? true, alias: resourceData.alias || null, aliasAddress: aliasAddress, disableIcmp: resourceData["disable-icmp"], tcpPortRangeString: resourceData["tcp-ports"], udpPortRangeString: resourceData["udp-ports"] }) .returning(); const siteResourceId = newResource.siteResourceId; const [adminRole] = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .limit(1); if (!adminRole) { throw new Error(`Admin role not found for org ${orgId}`); } await trx.insert(roleSiteResources).values({ roleId: adminRole.roleId, siteResourceId: siteResourceId }); if (resourceData.roles.length > 0) { // get roleIds from role names const rolesToUpdate = await trx .select() .from(roles) .where( and( eq(roles.orgId, orgId), inArray(roles.name, resourceData.roles) ) ); const roleIds = rolesToUpdate.map((role) => role.roleId); await trx .insert(roleSiteResources) .values( roleIds.map((roleId) => ({ roleId, siteResourceId })) ); } if (resourceData.users.length > 0) { // get userIds from username const usersToUpdate = await trx .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where( and( or( inArray(users.username, resourceData.users), inArray(users.email, resourceData.users) ), eq(userOrgs.orgId, orgId) ) ); const userIds = usersToUpdate.map((user) => user.user.userId); await trx .insert(userSiteResources) .values( userIds.map((userId) => ({ userId, siteResourceId })) ); } if (resourceData.machines.length > 0) { // get clientIds from niceIds const clientsToUpdate = await trx .select() .from(clients) .where( and( inArray(clients.niceId, resourceData.machines), eq(clients.orgId, orgId) ) ); const clientIds = clientsToUpdate.map( (client) => client.clientId ); await trx.insert(clientSiteResources).values( clientIds.map((clientId) => ({ clientId, siteResourceId })) ); } logger.info( `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` ); results.push({ newSiteResource: newResource }); } } return results; } ================================================ FILE: server/lib/blueprints/parseDockerContainers.ts ================================================ import logger from "@server/logger"; import { setNestedProperty } from "./parseDotNotation"; export type DockerLabels = { [key: string]: string; }; export type ParsedObject = { [key: string]: any; }; type ContainerPort = { privatePort: number; publicPort: number; type: string; ip: string; }; type Container = { id: string; name: string; image: string; state: string; status: string; ports: ContainerPort[] | null; labels: DockerLabels; created: number; networks: { [key: string]: any }; hostname: string; }; type Target = { hostname?: string; port?: number; method?: string; enabled?: boolean; [key: string]: any; }; type ResourceConfig = { [key: string]: any; targets?: (Target | null)[]; }; function getContainerPort(container: Container): number | null { if (!container.ports || container.ports.length === 0) { return null; } // Return the first port's privatePort return container.ports[0].privatePort; // return container.ports[0].publicPort; } export function processContainerLabels(containers: Container[]): { "proxy-resources": { [key: string]: ResourceConfig }; "client-resources": { [key: string]: ResourceConfig }; "public-resources": { [key: string]: ResourceConfig }; "private-resources": { [key: string]: ResourceConfig }; } { const result = { "proxy-resources": {} as { [key: string]: ResourceConfig }, "client-resources": {} as { [key: string]: ResourceConfig }, "public-resources": {} as { [key: string]: ResourceConfig }, "private-resources": {} as { [key: string]: ResourceConfig } }; // Process each container containers.forEach((container) => { if (container.state !== "running") { return; } const proxyResourceLabels: DockerLabels = {}; const clientResourceLabels: DockerLabels = {}; const publicResourceLabels: DockerLabels = {}; const privateResourceLabels: DockerLabels = {}; // Filter and separate proxy-resources, client-resources, public-resources, and private-resources labels Object.entries(container.labels).forEach(([key, value]) => { if (key.startsWith("pangolin.proxy-resources.")) { // remove the pangolin.proxy- prefix to get "resources.xxx" const strippedKey = key.replace("pangolin.proxy-", ""); proxyResourceLabels[strippedKey] = value; } else if (key.startsWith("pangolin.client-resources.")) { // remove the pangolin.client- prefix to get "resources.xxx" const strippedKey = key.replace("pangolin.client-", ""); clientResourceLabels[strippedKey] = value; } else if (key.startsWith("pangolin.public-resources.")) { // remove the pangolin.public- prefix to get "resources.xxx" const strippedKey = key.replace("pangolin.public-", ""); publicResourceLabels[strippedKey] = value; } else if (key.startsWith("pangolin.private-resources.")) { // remove the pangolin.private- prefix to get "resources.xxx" const strippedKey = key.replace("pangolin.private-", ""); privateResourceLabels[strippedKey] = value; } }); // Process proxy resources if (Object.keys(proxyResourceLabels).length > 0) { processResourceLabels( proxyResourceLabels, container, result["proxy-resources"] ); } // Process client resources if (Object.keys(clientResourceLabels).length > 0) { processResourceLabels( clientResourceLabels, container, result["client-resources"] ); } // Process public resources (alias for proxy resources) if (Object.keys(publicResourceLabels).length > 0) { processResourceLabels( publicResourceLabels, container, result["public-resources"] ); } // Process private resources (alias for client resources) if (Object.keys(privateResourceLabels).length > 0) { processResourceLabels( privateResourceLabels, container, result["private-resources"] ); } }); return result; } function processResourceLabels( resourceLabels: DockerLabels, container: Container, targetResult: { [key: string]: ResourceConfig } ) { // Parse the labels using the existing parseDockerLabels logic const tempResult: ParsedObject = {}; Object.entries(resourceLabels).forEach(([key, value]) => { setNestedProperty(tempResult, key, value); }); // Merge into target result if (tempResult.resources) { Object.entries(tempResult.resources).forEach( ([resourceKey, resourceConfig]: [string, any]) => { // Initialize resource if it doesn't exist if (!targetResult[resourceKey]) { targetResult[resourceKey] = {}; } // Merge all properties except targets Object.entries(resourceConfig).forEach( ([propKey, propValue]) => { if (propKey !== "targets") { targetResult[resourceKey][propKey] = propValue; } } ); // Handle targets specially if ( resourceConfig.targets && Array.isArray(resourceConfig.targets) ) { const resource = targetResult[resourceKey]; if (resource) { if (!resource.targets) { resource.targets = []; } resourceConfig.targets.forEach( (target: any, targetIndex: number) => { // check if the target is an empty object if ( typeof target === "object" && Object.keys(target).length === 0 ) { logger.debug( `Skipping null target at index ${targetIndex} for resource ${resourceKey}` ); resource.targets!.push(null); return; } // Ensure targets array is long enough while ( resource.targets!.length <= targetIndex ) { resource.targets!.push({}); } // Set default hostname and port if not provided const finalTarget = { ...target }; if (!finalTarget.hostname) { finalTarget.hostname = container.name || container.hostname; } if (!finalTarget.port) { const containerPort = getContainerPort(container); if (containerPort !== null) { finalTarget.port = containerPort; } } // Merge with existing target data resource.targets![targetIndex] = { ...resource.targets![targetIndex], ...finalTarget }; } ); } } } ); } } // // Test example // const testContainers: Container[] = [ // { // id: "57e056cb0e3a", // name: "nginx1", // image: "nginxdemos/hello", // state: "running", // status: "Up 4 days", // ports: [ // { // privatePort: 80, // publicPort: 8000, // type: "tcp", // ip: "0.0.0.0" // } // ], // labels: { // "resources.nginx.name": "nginx", // "resources.nginx.full-domain": "nginx.example.com", // "resources.nginx.protocol": "http", // "resources.nginx.targets[0].enabled": "true" // }, // created: 1756942725, // networks: { // owen_default: { // networkId: // "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c" // } // }, // hostname: "57e056cb0e3a" // }, // { // id: "58e056cb0e3b", // name: "nginx2", // image: "nginxdemos/hello", // state: "running", // status: "Up 4 days", // ports: [ // { // privatePort: 80, // publicPort: 8001, // type: "tcp", // ip: "0.0.0.0" // } // ], // labels: { // "resources.nginx.name": "nginx", // "resources.nginx.full-domain": "nginx.example.com", // "resources.nginx.protocol": "http", // "resources.nginx.targets[1].enabled": "true" // }, // created: 1756942726, // networks: { // owen_default: { // networkId: // "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c" // } // }, // hostname: "58e056cb0e3b" // }, // { // id: "59e056cb0e3c", // name: "api-server", // image: "my-api:latest", // state: "running", // status: "Up 2 days", // ports: [ // { // privatePort: 3000, // publicPort: 3000, // type: "tcp", // ip: "0.0.0.0" // } // ], // labels: { // "resources.api.name": "API Server", // "resources.api.protocol": "http", // "resources.api.targets[0].enabled": "true", // "resources.api.targets[0].hostname": "custom-host", // "resources.api.targets[0].port": "3001" // }, // created: 1756942727, // networks: { // owen_default: { // networkId: // "cb131c0f1d5d8ef7158660e77fc370508f5a563e1f9829b53a1945ae3725b58c" // } // }, // hostname: "59e056cb0e3c" // }, // { // id: "d0e29b08361c", // name: "beautiful_wilson", // image: "bolkedebruin/rdpgw:latest", // state: "exited", // status: "Exited (0) 4 hours ago", // ports: null, // labels: {}, // created: 1757359039, // networks: { // bridge: { // networkId: // "ea7f56dfc9cc476b8a3560b5b570d0fe8a6a2bc5e8343ab1ed37822086e89687" // } // }, // hostname: "d0e29b08361c" // } // ]; // // Test the function // const result = processContainerLabels(testContainers); // console.log("Processed result:"); // console.log(JSON.stringify(result, null, 2)); ================================================ FILE: server/lib/blueprints/parseDotNotation.ts ================================================ export function setNestedProperty(obj: any, path: string, value: string): void { const keys = path.split("."); let current = obj; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; // Handle array notation like "targets[0]" const arrayMatch = key.match(/^(.+)\[(\d+)\]$/); if (arrayMatch) { const [, arrayKey, indexStr] = arrayMatch; const index = parseInt(indexStr, 10); // Initialize array if it doesn't exist if (!current[arrayKey]) { current[arrayKey] = []; } // Ensure array is long enough while (current[arrayKey].length <= index) { current[arrayKey].push({}); } current = current[arrayKey][index]; } else { // Regular object property if (!current[key]) { current[key] = {}; } current = current[key]; } } // Set the final value const finalKey = keys[keys.length - 1]; const arrayMatch = finalKey.match(/^(.+)\[(\d+)\]$/); if (arrayMatch) { const [, arrayKey, indexStr] = arrayMatch; const index = parseInt(indexStr, 10); if (!current[arrayKey]) { current[arrayKey] = []; } // Ensure array is long enough while (current[arrayKey].length <= index) { current[arrayKey].push(null); } current[arrayKey][index] = convertValue(value); } else { current[finalKey] = convertValue(value); } } // Helper function to convert string values to appropriate types export function convertValue(value: string): any { // Convert boolean strings if (value === "true") return true; if (value === "false") return false; // Convert numeric strings if (/^\d+$/.test(value)) { const num = parseInt(value, 10); return num; } if (/^\d*\.\d+$/.test(value)) { const num = parseFloat(value); return num; } // Return as string return value; } // // Example usage: // const dockerLabels: DockerLabels = { // "resources.resource-nice-id.name": "this is my resource", // "resources.resource-nice-id.protocol": "http", // "resources.resource-nice-id.full-domain": "level1.test3.example.com", // "resources.resource-nice-id.host-header": "example.com", // "resources.resource-nice-id.tls-server-name": "example.com", // "resources.resource-nice-id.auth.pincode": "123456", // "resources.resource-nice-id.auth.password": "sadfasdfadsf", // "resources.resource-nice-id.auth.sso-enabled": "true", // "resources.resource-nice-id.auth.sso-roles[0]": "Member", // "resources.resource-nice-id.auth.sso-users[0]": "owen@pangolin.net", // "resources.resource-nice-id.auth.whitelist-users[0]": "owen@pangolin.net", // "resources.resource-nice-id.targets[0].hostname": "localhost", // "resources.resource-nice-id.targets[0].method": "http", // "resources.resource-nice-id.targets[0].port": "8000", // "resources.resource-nice-id.targets[0].healthcheck.port": "8000", // "resources.resource-nice-id.targets[0].healthcheck.hostname": "localhost", // "resources.resource-nice-id.targets[1].hostname": "localhost", // "resources.resource-nice-id.targets[1].method": "http", // "resources.resource-nice-id.targets[1].port": "8001", // "resources.resource-nice-id2.name": "this is other resource", // "resources.resource-nice-id2.protocol": "tcp", // "resources.resource-nice-id2.proxy-port": "3000", // "resources.resource-nice-id2.targets[0].hostname": "localhost", // "resources.resource-nice-id2.targets[0].port": "3000" // }; // // Parse the labels // const parsed = parseDockerLabels(dockerLabels); // console.log(JSON.stringify(parsed, null, 2)); ================================================ FILE: server/lib/blueprints/proxyResources.ts ================================================ import { domains, orgDomains, Resource, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, resourcePincode, resourceRules, resourceWhitelist, roleResources, roles, Target, TargetHealthCheck, targetHealthCheck, Transaction, userOrgs, userResources, users } from "@server/db"; import { resources, targets, sites } from "@server/db"; import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; import { Config, ConfigSchema, isTargetsOnlyResource, TargetData } from "./types"; import logger from "@server/logger"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { pickPort } from "@server/routers/target/helpers"; import { resourcePassword } from "@server/db"; import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "../billing/tierMatrix"; export type ProxyResourcesResults = { proxyResource: Resource; targetsToUpdate: Target[]; healthchecksToUpdate: TargetHealthCheck[]; }[]; export async function updateProxyResources( orgId: string, config: Config, trx: Transaction, siteId?: number ): Promise { const results: ProxyResourcesResults = []; for (const [resourceNiceId, resourceData] of Object.entries( config["proxy-resources"] )) { const targetsToUpdate: Target[] = []; const healthchecksToUpdate: TargetHealthCheck[] = []; let resource: Resource; async function createTarget( // reusable function to create a target resourceId: number, targetData: TargetData ) { const targetSiteId = targetData.site; let site; if (targetSiteId) { // Look up site by niceId [site] = await trx .select({ siteId: sites.siteId }) .from(sites) .where( and( eq(sites.niceId, targetSiteId), eq(sites.orgId, orgId) ) ) .limit(1); } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx .select({ siteId: sites.siteId }) .from(sites) .where( and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) ) .limit(1); } else { throw new Error(`Target site is required`); } if (!site) { throw new Error( `Site not found: ${targetSiteId} in org ${orgId}` ); } let internalPortToCreate; if (!targetData["internal-port"]) { const { internalPort, targetIps } = await pickPort( site.siteId!, trx ); internalPortToCreate = internalPort; } else { internalPortToCreate = targetData["internal-port"]; } // Create target const [newTarget] = await trx .insert(targets) .values({ resourceId: resourceId, siteId: site.siteId, ip: targetData.hostname, method: targetData.method, port: targetData.port, enabled: targetData.enabled, internalPort: internalPortToCreate, path: targetData.path, pathMatchType: targetData["path-match"], rewritePath: targetData.rewritePath || targetData["rewrite-path"] || (targetData["rewrite-match"] === "stripPrefix" ? "/" : undefined), rewritePathType: targetData["rewrite-match"], priority: targetData.priority }) .returning(); targetsToUpdate.push(newTarget); const healthcheckData = targetData.healthcheck; const hcHeaders = healthcheckData?.headers ? JSON.stringify(healthcheckData.headers) : null; const [newHealthcheck] = await trx .insert(targetHealthCheck) .values({ targetId: newTarget.targetId, hcEnabled: healthcheckData?.enabled || false, hcPath: healthcheckData?.path, hcScheme: healthcheckData?.scheme, hcMode: healthcheckData?.mode, hcHostname: healthcheckData?.hostname, hcPort: healthcheckData?.port, hcInterval: healthcheckData?.interval, hcUnhealthyInterval: healthcheckData?.unhealthyInterval || healthcheckData?.["unhealthy-interval"], hcTimeout: healthcheckData?.timeout, hcHeaders: hcHeaders, hcFollowRedirects: healthcheckData?.followRedirects || healthcheckData?.["follow-redirects"], hcMethod: healthcheckData?.method, hcStatus: healthcheckData?.status, hcHealth: "unknown" }) .returning(); healthchecksToUpdate.push(newHealthcheck); } // Find existing resource by niceId and orgId const [existingResource] = await trx .select() .from(resources) .where( and( eq(resources.niceId, resourceNiceId), eq(resources.orgId, orgId) ) ) .limit(1); const http = resourceData.protocol == "http"; const protocol = resourceData.protocol == "http" ? "tcp" : resourceData.protocol; const resourceEnabled = resourceData.enabled == undefined || resourceData.enabled == null ? true : resourceData.enabled; const resourceSsl = resourceData.ssl == undefined || resourceData.ssl == null ? true : resourceData.ssl; let headers = ""; if (resourceData.headers) { headers = JSON.stringify(resourceData.headers); } if (existingResource) { let domain; if (http) { domain = await getDomain( existingResource.resourceId, resourceData["full-domain"]!, orgId, trx ); } // check if the only key in the resource is targets, if so, skip the update if (isTargetsOnlyResource(resourceData)) { logger.debug( `Skipping update for resource ${existingResource.resourceId} as only targets are provided` ); resource = existingResource; } else { // Update existing resource const isLicensed = await isLicensedOrSubscribed( orgId, tierMatrix.maintencePage ); if (!isLicensed) { resourceData.maintenance = undefined; } [resource] = await trx .update(resources) .set({ name: resourceData.name || "Unnamed Resource", protocol: protocol || "tcp", http: http, proxyPort: http ? null : resourceData["proxy-port"], fullDomain: http ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, skipToIdpId: resourceData.auth?.["auto-login-idp"] || null, ssl: resourceSsl, setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, emailWhitelistEnabled: resourceData.auth?.[ "whitelist-users" ] ? resourceData.auth["whitelist-users"].length > 0 : false, headers: headers || null, applyRules: resourceData.rules && resourceData.rules.length > 0, maintenanceModeEnabled: resourceData.maintenance?.enabled, maintenanceModeType: resourceData.maintenance?.type, maintenanceTitle: resourceData.maintenance?.title, maintenanceMessage: resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"] }) .where( eq(resources.resourceId, existingResource.resourceId) ) .returning(); await trx .delete(resourcePassword) .where( eq( resourcePassword.resourceId, existingResource.resourceId ) ); if (resourceData.auth?.password) { const passwordHash = await hashPassword( resourceData.auth.password ); await trx.insert(resourcePassword).values({ resourceId: existingResource.resourceId, passwordHash }); } await trx .delete(resourcePincode) .where( eq( resourcePincode.resourceId, existingResource.resourceId ) ); if (resourceData.auth?.pincode) { const pincodeHash = await hashPassword( resourceData.auth.pincode.toString() ); await trx.insert(resourcePincode).values({ resourceId: existingResource.resourceId, pincodeHash, digitLength: 6 }); } await trx .delete(resourceHeaderAuth) .where( eq( resourceHeaderAuth.resourceId, existingResource.resourceId ) ); await trx .delete(resourceHeaderAuthExtendedCompatibility) .where( eq( resourceHeaderAuthExtendedCompatibility.resourceId, existingResource.resourceId ) ); if (resourceData.auth?.["basic-auth"]) { const headerAuthUser = resourceData.auth?.["basic-auth"]?.user; const headerAuthPassword = resourceData.auth?.["basic-auth"]?.password; const headerAuthExtendedCompatibility = resourceData.auth?.["basic-auth"] ?.extendedCompatibility; if ( headerAuthUser && headerAuthPassword && headerAuthExtendedCompatibility !== null ) { const headerAuthHash = await hashPassword( Buffer.from( `${headerAuthUser}:${headerAuthPassword}` ).toString("base64") ); await Promise.all([ trx.insert(resourceHeaderAuth).values({ resourceId: existingResource.resourceId, headerAuthHash }), trx .insert(resourceHeaderAuthExtendedCompatibility) .values({ resourceId: existingResource.resourceId, extendedCompatibilityIsActivated: headerAuthExtendedCompatibility }) ]); } } if (resourceData.auth?.["sso-roles"]) { const ssoRoles = resourceData.auth?.["sso-roles"]; await syncRoleResources( existingResource.resourceId, ssoRoles, orgId, trx ); } if (resourceData.auth?.["sso-users"]) { const ssoUsers = resourceData.auth?.["sso-users"]; await syncUserResources( existingResource.resourceId, ssoUsers, orgId, trx ); } if (resourceData.auth?.["whitelist-users"]) { const whitelistUsers = resourceData.auth?.["whitelist-users"]; await syncWhitelistUsers( existingResource.resourceId, whitelistUsers, orgId, trx ); } } const existingResourceTargets = await trx .select() .from(targets) .where(eq(targets.resourceId, existingResource.resourceId)) .orderBy(asc(targets.targetId)); // Create new targets for (const [index, targetData] of resourceData.targets.entries()) { if ( !targetData || (typeof targetData === "object" && Object.keys(targetData).length === 0) ) { // If targetData is null or an empty object, we can skip it continue; } const existingTarget = existingResourceTargets[index]; if (existingTarget) { const targetSiteId = targetData.site; let site; if (targetSiteId) { // Look up site by niceId [site] = await trx .select({ siteId: sites.siteId }) .from(sites) .where( and( eq(sites.niceId, targetSiteId), eq(sites.orgId, orgId) ) ) .limit(1); } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx .select({ siteId: sites.siteId }) .from(sites) .where( and( eq(sites.siteId, siteId), eq(sites.orgId, orgId) ) ) .limit(1); } else { throw new Error(`Target site is required`); } if (!site) { throw new Error( `Site not found: ${targetSiteId} in org ${orgId}` ); } // update this target const [updatedTarget] = await trx .update(targets) .set({ siteId: site.siteId, ip: targetData.hostname, method: http ? targetData.method : null, port: targetData.port, enabled: targetData.enabled, path: targetData.path, pathMatchType: targetData["path-match"], rewritePath: targetData.rewritePath || targetData["rewrite-path"] || (targetData["rewrite-match"] === "stripPrefix" ? "/" : undefined), rewritePathType: targetData["rewrite-match"], priority: targetData.priority }) .where(eq(targets.targetId, existingTarget.targetId)) .returning(); if (checkIfTargetChanged(existingTarget, updatedTarget)) { let internalPortToUpdate; if (!targetData["internal-port"]) { const { internalPort, targetIps } = await pickPort( site.siteId!, trx ); internalPortToUpdate = internalPort; } else { internalPortToUpdate = targetData["internal-port"]; } const [finalUpdatedTarget] = await trx // this double is so we can check the whole target before and after .update(targets) .set({ internalPort: internalPortToUpdate }) .where( eq(targets.targetId, existingTarget.targetId) ) .returning(); targetsToUpdate.push(finalUpdatedTarget); } const healthcheckData = targetData.healthcheck; const [oldHealthcheck] = await trx .select() .from(targetHealthCheck) .where( eq( targetHealthCheck.targetId, existingTarget.targetId ) ) .limit(1); const hcHeaders = healthcheckData?.headers ? JSON.stringify(healthcheckData.headers) : null; const [newHealthcheck] = await trx .update(targetHealthCheck) .set({ hcEnabled: healthcheckData?.enabled || false, hcPath: healthcheckData?.path, hcScheme: healthcheckData?.scheme, hcMode: healthcheckData?.mode, hcHostname: healthcheckData?.hostname, hcPort: healthcheckData?.port, hcInterval: healthcheckData?.interval, hcUnhealthyInterval: healthcheckData?.unhealthyInterval || healthcheckData?.["unhealthy-interval"], hcTimeout: healthcheckData?.timeout, hcHeaders: hcHeaders, hcFollowRedirects: healthcheckData?.followRedirects || healthcheckData?.["follow-redirects"], hcMethod: healthcheckData?.method, hcStatus: healthcheckData?.status }) .where( eq( targetHealthCheck.targetId, existingTarget.targetId ) ) .returning(); if ( checkIfHealthcheckChanged( oldHealthcheck, newHealthcheck ) ) { healthchecksToUpdate.push(newHealthcheck); // if the target is not already in the targetsToUpdate array, add it if ( !targetsToUpdate.find( (t) => t.targetId === updatedTarget.targetId ) ) { targetsToUpdate.push(updatedTarget); } } } else { await createTarget(existingResource.resourceId, targetData); } } if (existingResourceTargets.length > resourceData.targets.length) { const targetsToDelete = existingResourceTargets.slice( resourceData.targets.length ); logger.debug( `Targets to delete: ${JSON.stringify(targetsToDelete)}` ); for (const target of targetsToDelete) { if (!target) { continue; } if (siteId && target.siteId !== siteId) { logger.debug( `Skipping target ${target.targetId} for deletion. Site ID does not match filter.` ); continue; // only delete targets for the specified siteId } logger.debug(`Deleting target ${target.targetId}`); await trx .delete(targets) .where(eq(targets.targetId, target.targetId)); } } const existingRules = await trx .select() .from(resourceRules) .where( eq(resourceRules.resourceId, existingResource.resourceId) ) .orderBy(resourceRules.priority); // Sync rules for (const [index, rule] of resourceData.rules?.entries() || []) { const intendedPriority = rule.priority ?? index + 1; const existingRule = existingRules[index]; if (existingRule) { if ( existingRule.action !== getRuleAction(rule.action) || existingRule.match !== rule.match.toUpperCase() || existingRule.value !== getRuleValue( rule.match.toUpperCase(), rule.value ) || existingRule.priority !== intendedPriority ) { validateRule(rule); await trx .update(resourceRules) .set({ action: getRuleAction(rule.action), match: rule.match.toUpperCase(), value: getRuleValue( rule.match.toUpperCase(), rule.value ), priority: intendedPriority }) .where( eq(resourceRules.ruleId, existingRule.ruleId) ); } } else { validateRule(rule); await trx.insert(resourceRules).values({ resourceId: existingResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), value: getRuleValue( rule.match.toUpperCase(), rule.value ), priority: intendedPriority }); } } if (existingRules.length > (resourceData.rules?.length || 0)) { const rulesToDelete = existingRules.slice( resourceData.rules?.length || 0 ); for (const rule of rulesToDelete) { await trx .delete(resourceRules) .where(eq(resourceRules.ruleId, rule.ruleId)); } } logger.debug(`Updated resource ${existingResource.resourceId}`); } else { // create a brand new resource let domain; if (http) { domain = await getDomain( undefined, resourceData["full-domain"]!, orgId, trx ); } const isLicensed = await isLicensedOrSubscribed( orgId, tierMatrix.maintencePage ); if (!isLicensed) { resourceData.maintenance = undefined; } // Create new resource const [newResource] = await trx .insert(resources) .values({ orgId, niceId: resourceNiceId, name: resourceData.name || "Unnamed Resource", protocol: protocol || "tcp", http: http, proxyPort: http ? null : resourceData["proxy-port"], fullDomain: http ? resourceData["full-domain"] : null, subdomain: domain ? domain.subdomain : null, domainId: domain ? domain.domainId : null, enabled: resourceEnabled, sso: resourceData.auth?.["sso-enabled"] || false, skipToIdpId: resourceData.auth?.["auto-login-idp"] || null, setHostHeader: resourceData["host-header"] || null, tlsServerName: resourceData["tls-server-name"] || null, ssl: resourceSsl, headers: headers || null, applyRules: resourceData.rules && resourceData.rules.length > 0, maintenanceModeEnabled: resourceData.maintenance?.enabled, maintenanceModeType: resourceData.maintenance?.type, maintenanceTitle: resourceData.maintenance?.title, maintenanceMessage: resourceData.maintenance?.message, maintenanceEstimatedTime: resourceData.maintenance?.["estimated-time"] }) .returning(); if (resourceData.auth?.password) { const passwordHash = await hashPassword( resourceData.auth.password ); await trx.insert(resourcePassword).values({ resourceId: newResource.resourceId, passwordHash }); } if (resourceData.auth?.pincode) { const pincodeHash = await hashPassword( resourceData.auth.pincode.toString() ); await trx.insert(resourcePincode).values({ resourceId: newResource.resourceId, pincodeHash, digitLength: 6 }); } if (resourceData.auth?.["basic-auth"]) { const headerAuthUser = resourceData.auth?.["basic-auth"]?.user; const headerAuthPassword = resourceData.auth?.["basic-auth"]?.password; const headerAuthExtendedCompatibility = resourceData.auth?.["basic-auth"]?.extendedCompatibility; if ( headerAuthUser && headerAuthPassword && headerAuthExtendedCompatibility !== null ) { const headerAuthHash = await hashPassword( Buffer.from( `${headerAuthUser}:${headerAuthPassword}` ).toString("base64") ); await Promise.all([ trx.insert(resourceHeaderAuth).values({ resourceId: newResource.resourceId, headerAuthHash }), trx .insert(resourceHeaderAuthExtendedCompatibility) .values({ resourceId: newResource.resourceId, extendedCompatibilityIsActivated: headerAuthExtendedCompatibility }) ]); } } resource = newResource; const [adminRole] = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .limit(1); if (!adminRole) { throw new Error(`Admin role not found`); } await trx.insert(roleResources).values({ roleId: adminRole.roleId, resourceId: newResource.resourceId }); if (resourceData.auth?.["sso-roles"]) { const ssoRoles = resourceData.auth?.["sso-roles"]; await syncRoleResources( newResource.resourceId, ssoRoles, orgId, trx ); } if (resourceData.auth?.["sso-users"]) { const ssoUsers = resourceData.auth?.["sso-users"]; await syncUserResources( newResource.resourceId, ssoUsers, orgId, trx ); } if (resourceData.auth?.["whitelist-users"]) { const whitelistUsers = resourceData.auth?.["whitelist-users"]; await syncWhitelistUsers( newResource.resourceId, whitelistUsers, orgId, trx ); } // Create new targets for (const targetData of resourceData.targets) { if (!targetData) { // If targetData is null or an empty object, we can skip it continue; } await createTarget(newResource.resourceId, targetData); } for (const [index, rule] of resourceData.rules?.entries() || []) { validateRule(rule); await trx.insert(resourceRules).values({ resourceId: newResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), value: getRuleValue(rule.match.toUpperCase(), rule.value), priority: rule.priority ?? index + 1 }); } logger.debug(`Created resource ${newResource.resourceId}`); } results.push({ proxyResource: resource, targetsToUpdate, healthchecksToUpdate }); } return results; } function getRuleAction(input: string) { let action = "DROP"; if (input == "allow") { action = "ACCEPT"; } else if (input == "deny") { action = "DROP"; } else if (input == "pass") { action = "PASS"; } return action; } function getRuleValue(match: string, value: string) { // if the match is a country, uppercase the value if (match == "COUNTRY") { return value.toUpperCase(); } return value; } function validateRule(rule: any) { if (rule.match === "cidr") { if (!isValidCIDR(rule.value)) { throw new Error(`Invalid CIDR provided: ${rule.value}`); } } else if (rule.match === "ip") { if (!isValidIP(rule.value)) { throw new Error(`Invalid IP provided: ${rule.value}`); } } else if (rule.match === "path") { if (!isValidUrlGlobPattern(rule.value)) { throw new Error(`Invalid URL glob pattern: ${rule.value}`); } } } async function syncRoleResources( resourceId: number, ssoRoles: string[], orgId: string, trx: Transaction ) { const existingRoleResources = await trx .select() .from(roleResources) .where(eq(roleResources.resourceId, resourceId)); for (const roleName of ssoRoles) { const [role] = await trx .select() .from(roles) .where(and(eq(roles.name, roleName), eq(roles.orgId, orgId))) .limit(1); if (!role) { throw new Error(`Role not found: ${roleName} in org ${orgId}`); } if (role.isAdmin) { continue; // never add admin access } const existingRoleResource = existingRoleResources.find( (rr) => rr.roleId === role.roleId ); if (!existingRoleResource) { await trx.insert(roleResources).values({ roleId: role.roleId, resourceId: resourceId }); } } for (const existingRoleResource of existingRoleResources) { const [role] = await trx .select() .from(roles) .where(eq(roles.roleId, existingRoleResource.roleId)) .limit(1); if (role.isAdmin) { continue; // never remove admin access } if (role && !ssoRoles.includes(role.name)) { await trx .delete(roleResources) .where( and( eq(roleResources.roleId, existingRoleResource.roleId), eq(roleResources.resourceId, resourceId) ) ); } } } async function syncUserResources( resourceId: number, ssoUsers: string[], orgId: string, trx: Transaction ) { const existingUserResources = await trx .select() .from(userResources) .where(eq(userResources.resourceId, resourceId)); for (const username of ssoUsers) { const [user] = await trx .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where( and( or(eq(users.username, username), eq(users.email, username)), eq(userOrgs.orgId, orgId) ) ) .limit(1); if (!user) { throw new Error(`User not found: ${username} in org ${orgId}`); } const existingUserResource = existingUserResources.find( (rr) => rr.userId === user.user.userId ); if (!existingUserResource) { await trx.insert(userResources).values({ userId: user.user.userId, resourceId: resourceId }); } } for (const existingUserResource of existingUserResources) { const [user] = await trx .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where( and( eq(users.userId, existingUserResource.userId), eq(userOrgs.orgId, orgId) ) ) .limit(1); if ( user && user.user.username && !ssoUsers.includes(user.user.username) ) { await trx .delete(userResources) .where( and( eq(userResources.userId, existingUserResource.userId), eq(userResources.resourceId, resourceId) ) ); } } } async function syncWhitelistUsers( resourceId: number, whitelistUsers: string[], orgId: string, trx: Transaction ) { const existingWhitelist = await trx .select() .from(resourceWhitelist) .where(eq(resourceWhitelist.resourceId, resourceId)); for (const email of whitelistUsers) { const [user] = await trx .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where(and(eq(users.email, email), eq(userOrgs.orgId, orgId))) .limit(1); if (!user) { throw new Error(`User not found: ${email} in org ${orgId}`); } const existingWhitelistEntry = existingWhitelist.find( (w) => w.email === email ); if (!existingWhitelistEntry) { await trx.insert(resourceWhitelist).values({ email, resourceId: resourceId }); } } for (const existingWhitelistEntry of existingWhitelist) { if (!whitelistUsers.includes(existingWhitelistEntry.email)) { await trx .delete(resourceWhitelist) .where( and( eq(resourceWhitelist.resourceId, resourceId), eq( resourceWhitelist.email, existingWhitelistEntry.email ) ) ); } } } function checkIfHealthcheckChanged( existing: TargetHealthCheck | undefined, incoming: TargetHealthCheck | undefined ) { if (!existing && incoming) return true; if (existing && !incoming) return true; if (!existing || !incoming) return false; if (existing.hcEnabled !== incoming.hcEnabled) return true; if (existing.hcPath !== incoming.hcPath) return true; if (existing.hcScheme !== incoming.hcScheme) return true; if (existing.hcMode !== incoming.hcMode) return true; if (existing.hcHostname !== incoming.hcHostname) return true; if (existing.hcPort !== incoming.hcPort) return true; if (existing.hcInterval !== incoming.hcInterval) return true; if (existing.hcUnhealthyInterval !== incoming.hcUnhealthyInterval) return true; if (existing.hcTimeout !== incoming.hcTimeout) return true; if (existing.hcFollowRedirects !== incoming.hcFollowRedirects) return true; if (existing.hcMethod !== incoming.hcMethod) return true; if (existing.hcStatus !== incoming.hcStatus) return true; if ( JSON.stringify(existing.hcHeaders) !== JSON.stringify(incoming.hcHeaders) ) return true; return false; } function checkIfTargetChanged( existing: Target | undefined, incoming: Target | undefined ): boolean { if (!existing && incoming) return true; if (existing && !incoming) return true; if (!existing || !incoming) return false; if (existing.ip !== incoming.ip) return true; if (existing.port !== incoming.port) return true; if (existing.siteId !== incoming.siteId) return true; return false; } async function getDomain( resourceId: number | undefined, fullDomain: string, orgId: string, trx: Transaction ) { const [fullDomainExists] = await trx .select({ resourceId: resources.resourceId }) .from(resources) .where( and( eq(resources.fullDomain, fullDomain), eq(resources.orgId, orgId), resourceId ? ne(resources.resourceId, resourceId) : isNotNull(resources.resourceId) ) ) .limit(1); if (fullDomainExists) { throw new Error( `Resource already exists: ${fullDomain} in org ${orgId}` ); } const domain = await getDomainId(orgId, fullDomain, trx); if (!domain) { throw new Error( `Domain not found for full-domain: ${fullDomain} in org ${orgId}` ); } await createCertificate(domain.domainId, fullDomain, trx); return domain; } async function getDomainId( orgId: string, fullDomain: string, trx: Transaction ): Promise<{ subdomain: string | null; domainId: string } | null> { const possibleDomains = await trx .select() .from(domains) .innerJoin(orgDomains, eq(domains.domainId, orgDomains.domainId)) .where(and(eq(orgDomains.orgId, orgId), eq(domains.verified, true))) .execute(); if (possibleDomains.length === 0) { return null; } const validDomains = possibleDomains.filter((domain) => { if (domain.domains.type == "ns" || domain.domains.type == "wildcard") { return ( fullDomain === domain.domains.baseDomain || fullDomain.endsWith(`.${domain.domains.baseDomain}`) ); } else if (domain.domains.type == "cname") { return fullDomain === domain.domains.baseDomain; } }); if (validDomains.length === 0) { return null; } const domainSelection = validDomains[0].domains; const baseDomain = domainSelection.baseDomain; // remove the base domain of the domain let subdomain = null; if (fullDomain != baseDomain) { subdomain = fullDomain.replace(`.${baseDomain}`, ""); } // Return the first valid domain return { subdomain: subdomain, domainId: domainSelection.domainId }; } ================================================ FILE: server/lib/blueprints/types.ts ================================================ import { z } from "zod"; import { portRangeStringSchema } from "@server/lib/ip"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; export const SiteSchema = z.object({ name: z.string().min(1).max(100), "docker-socket-enabled": z.boolean().optional().default(true) }); export const TargetHealthCheckSchema = z.object({ hostname: z.string(), port: z.int().min(1).max(65535), enabled: z.boolean().optional().default(true), path: z.string().optional().default("/"), scheme: z.string().optional(), mode: z.string().default("http"), interval: z.int().default(30), "unhealthy-interval": z.int().default(30), unhealthyInterval: z.int().optional(), // deprecated alias timeout: z.int().default(5), headers: z .array(z.object({ name: z.string(), value: z.string() })) .nullable() .optional() .default(null), "follow-redirects": z.boolean().default(true), followRedirects: z.boolean().optional(), // deprecated alias method: z.string().default("GET"), status: z.int().optional() }); // Schema for individual target within a resource export const TargetSchema = z.object({ site: z.string().optional(), method: z.enum(["http", "https", "h2c"]).optional(), hostname: z.string(), port: z.int().min(1).max(65535), enabled: z.boolean().optional().default(true), "internal-port": z.int().min(1).max(65535).optional(), path: z.string().optional(), "path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(), healthcheck: TargetHealthCheckSchema.optional(), rewritePath: z.string().optional(), // deprecated alias "rewrite-path": z.string().optional(), "rewrite-match": z .enum(["exact", "prefix", "regex", "stripPrefix"]) .optional() .nullable(), priority: z.int().min(1).max(1000).optional().default(100) }); export type TargetData = z.infer; export const AuthSchema = z.object({ // pincode has to have 6 digits pincode: z.number().min(100000).max(999999).optional(), password: z.string().min(1).optional(), "basic-auth": z .object({ user: z.string().min(1), password: z.string().min(1), extendedCompatibility: z.boolean().default(true) }) .optional(), "sso-enabled": z.boolean().optional().default(false), "sso-roles": z .array(z.string()) .optional() .default([]) .refine((roles) => !roles.includes("Admin"), { error: "Admin role cannot be included in sso-roles" }), "sso-users": z.array(z.string()).optional().default([]), "whitelist-users": z.array(z.email()).optional().default([]), "auto-login-idp": z.int().positive().optional() }); export const RuleSchema = z .object({ action: z.enum(["allow", "deny", "pass"]), match: z.enum(["cidr", "path", "ip", "country", "asn"]), value: z.string(), priority: z.int().optional() }) .refine( (rule) => { if (rule.match === "ip") { // Check if it's a valid IP address (v4 or v6) return z.union([z.ipv4(), z.ipv6()]).safeParse(rule.value) .success; } return true; }, { path: ["value"], message: "Value must be a valid IP address when match is 'ip'" } ) .refine( (rule) => { if (rule.match === "cidr") { // Check if it's a valid CIDR (v4 or v6) return z.union([z.cidrv4(), z.cidrv6()]).safeParse(rule.value) .success; } return true; }, { path: ["value"], message: "Value must be a valid CIDR notation when match is 'cidr'" } ) .refine( (rule) => { if (rule.match === "country") { // Check if it's a valid 2-letter country code or "ALL" return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL"; } return true; }, { path: ["value"], message: "Value must be a 2-letter country code or 'ALL' when match is 'country'" } ) .refine( (rule) => { if (rule.match === "asn") { // Check if it's either AS format or "ALL" const asNumberPattern = /^AS\d+$/i; return asNumberPattern.test(rule.value) || rule.value === "ALL"; } return true; }, { path: ["value"], message: "Value must be 'AS' format or 'ALL' when match is 'asn'" } ); export const HeaderSchema = z.object({ name: z.string().min(1), value: z.string().min(1) }); // Schema for individual resource export const ResourceSchema = z .object({ name: z.string().optional(), protocol: z.enum(["http", "tcp", "udp"]).optional(), ssl: z.boolean().optional(), "full-domain": z.string().optional(), "proxy-port": z.int().min(1).max(65535).optional(), enabled: z.boolean().optional(), targets: z.array(TargetSchema.nullable()).optional().default([]), auth: AuthSchema.optional(), "host-header": z.string().optional(), "tls-server-name": z.string().optional(), headers: z.array(HeaderSchema).optional(), rules: z.array(RuleSchema).optional(), maintenance: MaintenanceSchema.optional() }) .refine( (resource) => { if (isTargetsOnlyResource(resource)) { return true; } // Otherwise, require name and protocol for full resource definition return ( resource.name !== undefined && resource.protocol !== undefined ); }, { path: ["name", "protocol"], error: "Resource must either be targets-only (only 'targets' field) or have both 'name' and 'protocol' fields at a minimum" } ) .refine( (resource) => { if (isTargetsOnlyResource(resource)) { return true; } // If protocol is http, all targets must have method field if (resource.protocol === "http") { return resource.targets.every( (target) => target == null || target.method !== undefined ); } return true; }, { path: ["targets"], error: "When protocol is 'http', all targets must have a 'method' field" } ) .refine( (resource) => { if (isTargetsOnlyResource(resource)) { return true; } // If protocol is tcp or udp, no target should have method field if (resource.protocol === "tcp" || resource.protocol === "udp") { return resource.targets.every( (target) => target == null || target.method === undefined ); } return true; }, { path: ["targets"], error: "When protocol is 'tcp' or 'udp', targets must not have a 'method' field" } ) .refine( (resource) => { if (isTargetsOnlyResource(resource)) { return true; } // If protocol is http, it must have a full-domain if (resource.protocol === "http") { return ( resource["full-domain"] !== undefined && resource["full-domain"].length > 0 ); } return true; }, { path: ["full-domain"], error: "When protocol is 'http', a 'full-domain' must be provided" } ) .refine( (resource) => { if (isTargetsOnlyResource(resource)) { return true; } // If protocol is tcp or udp, it must have both proxy-port if (resource.protocol === "tcp" || resource.protocol === "udp") { return resource["proxy-port"] !== undefined; } return true; }, { path: ["proxy-port", "exit-node"], error: "When protocol is 'tcp' or 'udp', 'proxy-port' must be provided" } ) .refine( (resource) => { // Skip validation for targets-only resources if (isTargetsOnlyResource(resource)) { return true; } // If protocol is tcp or udp, it must not have auth if (resource.protocol === "tcp" || resource.protocol === "udp") { return resource.auth === undefined; } return true; }, { path: ["auth"], error: "When protocol is 'tcp' or 'udp', 'auth' must not be provided" } ) .refine( (resource) => { // Skip validation for targets-only resources if (isTargetsOnlyResource(resource)) { return true; } // Skip validation if no rules are defined if (!resource.rules || resource.rules.length === 0) return true; const finalPriorities: number[] = []; let priorityCounter = 1; // Gather priorities, assigning auto-priorities where needed // following the logic from the backend implementation where // empty priorities are auto-assigned a value of 1 + index of rule for (const rule of resource.rules) { if (rule.priority !== undefined) { finalPriorities.push(rule.priority); } else { finalPriorities.push(priorityCounter); } priorityCounter++; } // Validate for duplicate priorities return finalPriorities.length === new Set(finalPriorities).size; }, { path: ["rules"], message: "Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)" } ); export function isTargetsOnlyResource(resource: any): boolean { return Object.keys(resource).length === 1 && resource.targets; } export const ClientResourceSchema = z .object({ name: z.string().min(1).max(255), mode: z.enum(["host", "cidr"]), site: z.string(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(), destination: z.string().min(1), // enabled: z.boolean().default(true), "tcp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"), "disable-icmp": z.boolean().optional().default(false), alias: z .string() .regex( /^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, "Alias must be a fully qualified domain name with optional wildcards (e.g., example.com, *.example.com, host-0?.example.internal)" ) .optional(), roles: z .array(z.string()) .optional() .default([]) .refine((roles) => !roles.includes("Admin"), { error: "Admin role cannot be included in roles" }), users: z.array(z.string()).optional().default([]), machines: z.array(z.string()).optional().default([]) }) .refine( (data) => { if (data.mode === "host") { // Check if it's a valid IP address using zod (v4 or v6) const isValidIP = z .union([z.ipv4(), z.ipv6()]) .safeParse(data.destination).success; if (isValidIP) { return true; } // Check if it's a valid domain (hostname pattern, TLD not required) const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; const isValidDomain = domainRegex.test(data.destination); const isValidAlias = data.alias && domainRegex.test(data.alias); return isValidDomain && isValidAlias; // require the alias to be set in the case of domain } return true; }, { message: "Destination must be a valid IP address or valid domain AND alias is required" } ) .refine( (data) => { if (data.mode === "cidr") { // Check if it's a valid CIDR (v4 or v6) const isValidCIDR = z .union([z.cidrv4(), z.cidrv6()]) .safeParse(data.destination).success; return isValidCIDR; } return true; }, { message: "Destination must be a valid CIDR notation for cidr mode" } ); // Schema for the entire configuration object export const ConfigSchema = z .object({ "proxy-resources": z .record(z.string(), ResourceSchema) .optional() .prefault({}), "public-resources": z .record(z.string(), ResourceSchema) .optional() .prefault({}), "client-resources": z .record(z.string(), ClientResourceSchema) .optional() .prefault({}), "private-resources": z .record(z.string(), ClientResourceSchema) .optional() .prefault({}), sites: z.record(z.string(), SiteSchema).optional().prefault({}) }) .transform((data) => { // Merge public-resources into proxy-resources if (data["public-resources"]) { data["proxy-resources"] = { ...data["proxy-resources"], ...data["public-resources"] }; delete (data as any)["public-resources"]; } // Merge private-resources into client-resources if (data["private-resources"]) { data["client-resources"] = { ...data["client-resources"], ...data["private-resources"] }; delete (data as any)["private-resources"]; } return data as { "proxy-resources": Record>; "client-resources": Record< string, z.infer >; sites: Record>; }; }) .superRefine((config, ctx) => { // Enforce the full-domain uniqueness across resources in the same stack const fullDomainMap = new Map(); Object.entries(config["proxy-resources"]).forEach( ([resourceKey, resource]) => { const fullDomain = resource["full-domain"]; if (fullDomain) { // Only process if full-domain is defined if (!fullDomainMap.has(fullDomain)) { fullDomainMap.set(fullDomain, []); } fullDomainMap.get(fullDomain)!.push(resourceKey); } } ); const fullDomainDuplicates = Array.from(fullDomainMap.entries()) .filter(([_, resourceKeys]) => resourceKeys.length > 1) .map( ([fullDomain, resourceKeys]) => `'${fullDomain}' used by resources: ${resourceKeys.join(", ")}` ) .join("; "); if (fullDomainDuplicates.length !== 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["proxy-resources"], message: `Duplicate 'full-domain' values found: ${fullDomainDuplicates}` }); } // Enforce proxy-port uniqueness within proxy-resources per protocol const protocolPortMap = new Map(); Object.entries(config["proxy-resources"]).forEach( ([resourceKey, resource]) => { const proxyPort = resource["proxy-port"]; const protocol = resource.protocol; if (proxyPort !== undefined && protocol !== undefined) { const key = `${protocol}:${proxyPort}`; if (!protocolPortMap.has(key)) { protocolPortMap.set(key, []); } protocolPortMap.get(key)!.push(resourceKey); } } ); const portDuplicates = Array.from(protocolPortMap.entries()) .filter(([_, resourceKeys]) => resourceKeys.length > 1) .map(([protocolPort, resourceKeys]) => { const [protocol, port] = protocolPort.split(":"); return `${protocol.toUpperCase()} port ${port} used by proxy-resources: ${resourceKeys.join(", ")}`; }) .join("; "); if (portDuplicates.length !== 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["proxy-resources"], message: `Duplicate 'proxy-port' values found in proxy-resources: ${portDuplicates}` }); } // Enforce alias uniqueness within client-resources const aliasMap = new Map(); Object.entries(config["client-resources"]).forEach( ([resourceKey, resource]) => { const alias = resource.alias; if (alias !== undefined) { if (!aliasMap.has(alias)) { aliasMap.set(alias, []); } aliasMap.get(alias)!.push(resourceKey); } } ); const aliasDuplicates = Array.from(aliasMap.entries()) .filter(([_, resourceKeys]) => resourceKeys.length > 1) .map( ([alias, resourceKeys]) => `alias '${alias}' used by client-resources: ${resourceKeys.join(", ")}` ) .join("; "); if (aliasDuplicates.length !== 0) { ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["client-resources"], message: `Duplicate 'alias' values found in client-resources: ${aliasDuplicates}` }); } }); // Type inference from the schema export type Site = z.infer; export type Target = z.infer; export type Resource = z.infer; export type Config = z.infer; ================================================ FILE: server/lib/cache.ts ================================================ import NodeCache from "node-cache"; import logger from "@server/logger"; // Create local cache with maxKeys limit to prevent memory leaks // With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient export const localCache = new NodeCache({ stdTTL: 3600, checkperiod: 120, maxKeys: 10000 }); // Log cache statistics periodically for monitoring setInterval(() => { const stats = localCache.getStats(); logger.debug( `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%` ); }, 300000); // Every 5 minutes /** * Adaptive cache that uses Redis when available in multi-node environments, * otherwise falls back to local memory cache for single-node deployments. */ class AdaptiveCache { /** * Set a value in the cache * @param key - Cache key * @param value - Value to cache (will be JSON stringified for Redis) * @param ttl - Time to live in seconds (0 = no expiration) * @returns boolean indicating success */ async set(key: string, value: any, ttl?: number): Promise { const effectiveTtl = ttl === 0 ? undefined : ttl; // Use local cache as fallback or primary const success = localCache.set(key, value, effectiveTtl || 0); if (success) { logger.debug(`Set key in local cache: ${key}`); } return success; } /** * Get a value from the cache * @param key - Cache key * @returns The cached value or undefined if not found */ async get(key: string): Promise { // Use local cache as fallback or primary const value = localCache.get(key); if (value !== undefined) { logger.debug(`Cache hit in local cache: ${key}`); } else { logger.debug(`Cache miss in local cache: ${key}`); } return value; } /** * Delete a value from the cache * @param key - Cache key or array of keys * @returns Number of deleted entries */ async del(key: string | string[]): Promise { const keys = Array.isArray(key) ? key : [key]; let deletedCount = 0; // Use local cache as fallback or primary for (const k of keys) { const success = localCache.del(k); if (success > 0) { deletedCount++; logger.debug(`Deleted key from local cache: ${k}`); } } return deletedCount; } /** * Check if a key exists in the cache * @param key - Cache key * @returns boolean indicating if key exists */ async has(key: string): Promise { // Use local cache as fallback or primary return localCache.has(key); } /** * Get multiple values from the cache * @param keys - Array of cache keys * @returns Array of values (undefined for missing keys) */ async mget(keys: string[]): Promise<(T | undefined)[]> { // Use local cache as fallback or primary return keys.map((key) => localCache.get(key)); } /** * Flush all keys from the cache */ async flushAll(): Promise { localCache.flushAll(); logger.debug("Flushed local cache"); } /** * Get cache statistics * Note: Only returns local cache stats, Redis stats are not included */ getStats() { return localCache.getStats(); } /** * Get the current cache backend being used * @returns "redis" if Redis is available and healthy, "local" otherwise */ getCurrentBackend(): "redis" | "local" { return "local"; } /** * Take a key from the cache and delete it * @param key - Cache key * @returns The value or undefined if not found */ async take(key: string): Promise { const value = await this.get(key); if (value !== undefined) { await this.del(key); } return value; } /** * Get TTL (time to live) for a key * @param key - Cache key * @returns TTL in seconds, 0 if no expiration, -1 if key doesn't exist */ getTtl(key: string): number { const ttl = localCache.getTtl(key); if (ttl === undefined) { return -1; } return Math.max(0, Math.floor((ttl - Date.now()) / 1000)); } /** * Get all keys from the cache * Note: Only returns local cache keys, Redis keys are not included */ keys(): string[] { return localCache.keys(); } } // Export singleton instance export const cache = new AdaptiveCache(); export default cache; ================================================ FILE: server/lib/calculateUserClientsForOrgs.ts ================================================ import { listExitNodes } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; import { approvals, clients, db, olms, orgs, roleClients, roles, Transaction, userClients, userOrgs } from "@server/db"; import { getUniqueClientName } from "@server/db/names"; import { getNextAvailableClientSubnet } from "@server/lib/ip"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import logger from "@server/logger"; import { sendTerminateClient } from "@server/routers/client/terminate"; import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm"; import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations"; import { OlmErrorCodes } from "@server/routers/olm/error"; import { tierMatrix } from "./billing/tierMatrix"; export async function calculateUserClientsForOrgs( userId: string, trx?: Transaction ): Promise { const execute = async (transaction: Transaction) => { // Get all OLMs for this user const userOlms = await transaction .select() .from(olms) .where(eq(olms.userId, userId)); if (userOlms.length === 0) { // No OLMs for this user, but we should still clean up any orphaned clients await cleanupOrphanedClients(userId, transaction); return; } // Get all user orgs const allUserOrgs = await transaction .select() .from(userOrgs) .innerJoin(roles, eq(roles.roleId, userOrgs.roleId)) .where(eq(userOrgs.userId, userId)); const userOrgIds = allUserOrgs.map(({ userOrgs: uo }) => uo.orgId); // For each OLM, ensure there's a client in each org the user is in for (const olm of userOlms) { for (const userRoleOrg of allUserOrgs) { const { userOrgs: userOrg, roles: role } = userRoleOrg; const orgId = userOrg.orgId; const [org] = await transaction .select() .from(orgs) .where(eq(orgs.orgId, orgId)); if (!org) { logger.warn( `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found` ); continue; } if (!org.subnet) { logger.warn( `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured` ); continue; } // Get admin role for this org (needed for access grants) const [adminRole] = await transaction .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .limit(1); if (!adminRole) { logger.warn( `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found` ); continue; } // Check if a client already exists for this OLM+user+org combination const [existingClient] = await transaction .select() .from(clients) .where( and( eq(clients.userId, userId), eq(clients.orgId, orgId), eq(clients.olmId, olm.olmId) ) ) .limit(1); if (existingClient) { // Ensure admin role has access to the client const [existingRoleClient] = await transaction .select() .from(roleClients) .where( and( eq(roleClients.roleId, adminRole.roleId), eq( roleClients.clientId, existingClient.clientId ) ) ) .limit(1); if (!existingRoleClient) { await transaction.insert(roleClients).values({ roleId: adminRole.roleId, clientId: existingClient.clientId }); logger.debug( `Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` ); } // Ensure user has access to the client const [existingUserClient] = await transaction .select() .from(userClients) .where( and( eq(userClients.userId, userId), eq( userClients.clientId, existingClient.clientId ) ) ) .limit(1); if (!existingUserClient) { await transaction.insert(userClients).values({ userId, clientId: existingClient.clientId }); logger.debug( `Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})` ); } logger.debug( `Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation` ); continue; } // Get exit nodes for this org const exitNodesList = await listExitNodes(orgId); if (exitNodesList.length === 0) { logger.warn( `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found` ); continue; } const randomExitNode = exitNodesList[ Math.floor(Math.random() * exitNodesList.length) ]; // Get next available subnet const newSubnet = await getNextAvailableClientSubnet( orgId, transaction ); if (!newSubnet) { logger.warn( `Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found` ); continue; } const subnet = newSubnet.split("/")[0]; const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; const niceId = await getUniqueClientName(orgId); const isOrgLicensed = await isLicensedOrSubscribed( userOrg.orgId, tierMatrix.deviceApprovals ); const requireApproval = build !== "oss" && isOrgLicensed && role.requireDeviceApproval; const newClientData: InferInsertModel = { userId, orgId: userOrg.orgId, exitNodeId: randomExitNode.exitNodeId, name: olm.name || "User Client", subnet: updatedSubnet, olmId: olm.olmId, type: "olm", niceId, approvalState: requireApproval ? "pending" : null }; // Create the client const [newClient] = await transaction .insert(clients) .values(newClientData) .returning(); // create approval request if (requireApproval) { await transaction .insert(approvals) .values({ timestamp: Math.floor(new Date().getTime() / 1000), orgId: userOrg.orgId, clientId: newClient.clientId, userId, type: "user_device" }) .returning(); } await rebuildClientAssociationsFromClient( newClient, transaction ); // Grant admin role access to the client await transaction.insert(roleClients).values({ roleId: adminRole.roleId, clientId: newClient.clientId }); // Grant user access to the client await transaction.insert(userClients).values({ userId, clientId: newClient.clientId }); logger.debug( `Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user` ); } } // Clean up clients in orgs the user is no longer in await cleanupOrphanedClients(userId, transaction, userOrgIds); }; if (trx) { // Use provided transaction await execute(trx); } else { // Create new transaction await db.transaction(async (transaction) => { await execute(transaction); }); } } async function cleanupOrphanedClients( userId: string, trx: Transaction, userOrgIds: string[] = [] ): Promise { // Find all OLM clients for this user that should be deleted // If userOrgIds is empty, delete all OLM clients (user has no orgs) // If userOrgIds has values, delete clients in orgs they're not in const clientsToDelete = await trx .select({ clientId: clients.clientId }) .from(clients) .where( userOrgIds.length > 0 ? and( eq(clients.userId, userId), notInArray(clients.orgId, userOrgIds) ) : and(eq(clients.userId, userId)) ); if (clientsToDelete.length > 0) { const deletedClients = await trx .delete(clients) .where( userOrgIds.length > 0 ? and( eq(clients.userId, userId), notInArray(clients.orgId, userOrgIds) ) : and(eq(clients.userId, userId)) ) .returning(); // Rebuild associations for each deleted client to clean up related data for (const deletedClient of deletedClients) { await rebuildClientAssociationsFromClient(deletedClient, trx); if (deletedClient.olmId) { await sendTerminateClient( deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, deletedClient.olmId ); } } if (userOrgIds.length === 0) { logger.debug( `Deleted all ${clientsToDelete.length} OLM client(s) for user ${userId} (user has no orgs)` ); } else { logger.debug( `Deleted ${clientsToDelete.length} orphaned OLM client(s) for user ${userId} in orgs they're no longer in` ); } } } ================================================ FILE: server/lib/canUserAccessResource.ts ================================================ import { db } from "@server/db"; import { and, eq } from "drizzle-orm"; import { roleResources, userResources } from "@server/db"; export async function canUserAccessResource({ userId, resourceId, roleId }: { userId: string; resourceId: number; roleId: number; }): Promise { const roleResourceAccess = await db .select() .from(roleResources) .where( and( eq(roleResources.resourceId, resourceId), eq(roleResources.roleId, roleId) ) ) .limit(1); if (roleResourceAccess.length > 0) { return true; } const userResourceAccess = await db .select() .from(userResources) .where( and( eq(userResources.userId, userId), eq(userResources.resourceId, resourceId) ) ) .limit(1); if (userResourceAccess.length > 0) { return true; } return false; } ================================================ FILE: server/lib/certificates.ts ================================================ export async function getValidCertificatesForDomains( domains: Set ): Promise< Array<{ id: number; domain: string; wildcard: boolean | null; certFile: string | null; keyFile: string | null; expiresAt: number | null; updatedAt?: number | null; }> > { return []; // stub } ================================================ FILE: server/lib/checkOrgAccessPolicy.ts ================================================ import { Org, ResourceSession, Session, User } from "@server/db"; export type CheckOrgAccessPolicyProps = { orgId?: string; org?: Org; userId?: string; user?: User; sessionId?: string; session?: Session; }; export type CheckOrgAccessPolicyResult = { allowed: boolean; error?: string; policies?: { requiredTwoFactor?: boolean; maxSessionLength?: { compliant: boolean; maxSessionLengthHours: number; sessionAgeHours: number; }; passwordAge?: { compliant: boolean; maxPasswordAgeDays: number; passwordAgeDays: number; }; }; }; export async function enforceResourceSessionLength( resourceSession: ResourceSession, org: Org ): Promise<{ valid: boolean; error?: string }> { return { valid: true }; } export async function checkOrgAccessPolicy( props: CheckOrgAccessPolicyProps ): Promise { return { allowed: true }; } ================================================ FILE: server/lib/cleanupLogs.test.ts ================================================ import { assertEquals } from "@test/assert"; // Helper to create a timestamp from a date string (UTC) function dateToTimestamp(dateStr: string): number { return Math.floor(new Date(dateStr).getTime() / 1000); } // Testable version of calculateCutoffTimestamp that accepts a "now" timestamp // This matches the logic in cleanupLogs.ts but allows injecting the current time function calculateCutoffTimestampWithNow( retentionDays: number, nowTimestamp: number ): number { if (retentionDays === 9001) { // Special case: data is erased at the end of the year following the year it was generated // This means we delete logs from 2 years ago or older (logs from year Y are deleted after Dec 31 of year Y+1) const currentYear = new Date(nowTimestamp * 1000).getUTCFullYear(); // Cutoff is the start of the year before last (Jan 1, currentYear - 1 at 00:00:00) // Any logs before this date are from 2+ years ago and should be deleted const cutoffDate = new Date(Date.UTC(currentYear - 1, 0, 1, 0, 0, 0)); return Math.floor(cutoffDate.getTime() / 1000); } else { return nowTimestamp - retentionDays * 24 * 60 * 60; } } function testCalculateCutoffTimestamp() { console.log("Running calculateCutoffTimestamp tests..."); // Test 1: Normal retention days (e.g., 30 days) { const now = dateToTimestamp("2025-12-06T12:00:00Z"); const result = calculateCutoffTimestampWithNow(30, now); const expected = now - 30 * 24 * 60 * 60; assertEquals(result, expected, "30 days retention calculation failed"); } // Test 2: Normal retention days (e.g., 90 days) { const now = dateToTimestamp("2025-06-15T00:00:00Z"); const result = calculateCutoffTimestampWithNow(90, now); const expected = now - 90 * 24 * 60 * 60; assertEquals(result, expected, "90 days retention calculation failed"); } // Test 3: Special case 9001 - December 2025 (before Dec 31) // Data from 2024 should NOT be deleted yet (must wait until after Dec 31, 2025) // Data from 2023 and earlier should be deleted // Cutoff should be Jan 1, 2024 (start of currentYear - 1) { const now = dateToTimestamp("2025-12-06T12:00:00Z"); const result = calculateCutoffTimestampWithNow(9001, now); const expected = dateToTimestamp("2024-01-01T00:00:00Z"); assertEquals( result, expected, "9001 retention (Dec 2025) - should cutoff at Jan 1, 2024" ); } // Test 4: Special case 9001 - January 2026 // Data from 2024 should now be deleted (Dec 31, 2025 has passed) // Cutoff should be Jan 1, 2025 (start of currentYear - 1) { const now = dateToTimestamp("2026-01-15T12:00:00Z"); const result = calculateCutoffTimestampWithNow(9001, now); const expected = dateToTimestamp("2025-01-01T00:00:00Z"); assertEquals( result, expected, "9001 retention (Jan 2026) - should cutoff at Jan 1, 2025" ); } // Test 5: Special case 9001 - December 31, 2025 at 23:59:59 UTC // Still in 2025, so data from 2024 should NOT be deleted yet // Cutoff should be Jan 1, 2024 { const now = dateToTimestamp("2025-12-31T23:59:59Z"); const result = calculateCutoffTimestampWithNow(9001, now); const expected = dateToTimestamp("2024-01-01T00:00:00Z"); assertEquals( result, expected, "9001 retention (Dec 31, 2025 23:59:59) - should cutoff at Jan 1, 2024" ); } // Test 6: Special case 9001 - January 1, 2026 at 00:00:01 UTC // Now in 2026, so data from 2024 should be deleted // Cutoff should be Jan 1, 2025 { const now = dateToTimestamp("2026-01-01T00:00:01Z"); const result = calculateCutoffTimestampWithNow(9001, now); const expected = dateToTimestamp("2025-01-01T00:00:00Z"); assertEquals( result, expected, "9001 retention (Jan 1, 2026 00:00:01) - should cutoff at Jan 1, 2025" ); } // Test 7: Special case 9001 - Mid year 2025 // Cutoff should still be Jan 1, 2024 { const now = dateToTimestamp("2025-06-15T12:00:00Z"); const result = calculateCutoffTimestampWithNow(9001, now); const expected = dateToTimestamp("2024-01-01T00:00:00Z"); assertEquals( result, expected, "9001 retention (mid 2025) - should cutoff at Jan 1, 2024" ); } // Test 8: Special case 9001 - Early 2024 // Cutoff should be Jan 1, 2023 { const now = dateToTimestamp("2024-02-01T12:00:00Z"); const result = calculateCutoffTimestampWithNow(9001, now); const expected = dateToTimestamp("2023-01-01T00:00:00Z"); assertEquals( result, expected, "9001 retention (early 2024) - should cutoff at Jan 1, 2023" ); } // Test 9: 1 day retention { const now = dateToTimestamp("2025-12-06T12:00:00Z"); const result = calculateCutoffTimestampWithNow(1, now); const expected = now - 1 * 24 * 60 * 60; assertEquals(result, expected, "1 day retention calculation failed"); } // Test 10: 365 days retention (1 year) { const now = dateToTimestamp("2025-12-06T12:00:00Z"); const result = calculateCutoffTimestampWithNow(365, now); const expected = now - 365 * 24 * 60 * 60; assertEquals(result, expected, "365 days retention calculation failed"); } // Test 11: Verify 9001 deletes logs correctly across year boundary // If we're in 2025, logs from Dec 31, 2023 (timestamp) should be DELETED (before cutoff) // But logs from Jan 1, 2024 (timestamp) should be KEPT (at or after cutoff) { const now = dateToTimestamp("2025-12-06T12:00:00Z"); const cutoff = calculateCutoffTimestampWithNow(9001, now); const logFromDec2023 = dateToTimestamp("2023-12-31T23:59:59Z"); const logFromJan2024 = dateToTimestamp("2024-01-01T00:00:00Z"); // Log from Dec 2023 should be before cutoff (deleted) assertEquals( logFromDec2023 < cutoff, true, "Log from Dec 2023 should be deleted" ); // Log from Jan 2024 should be at or after cutoff (kept) assertEquals( logFromJan2024 >= cutoff, true, "Log from Jan 2024 should be kept" ); } // Test 12: Verify 9001 in 2026 - logs from 2024 should now be deleted { const now = dateToTimestamp("2026-03-15T12:00:00Z"); const cutoff = calculateCutoffTimestampWithNow(9001, now); const logFromDec2024 = dateToTimestamp("2024-12-31T23:59:59Z"); const logFromJan2025 = dateToTimestamp("2025-01-01T00:00:00Z"); // Log from Dec 2024 should be before cutoff (deleted) assertEquals( logFromDec2024 < cutoff, true, "Log from Dec 2024 should be deleted in 2026" ); // Log from Jan 2025 should be at or after cutoff (kept) assertEquals( logFromJan2025 >= cutoff, true, "Log from Jan 2025 should be kept in 2026" ); } // Test 13: Edge case - exactly at year boundary for 9001 // On Jan 1, 2025 00:00:00 UTC, cutoff should be Jan 1, 2024 { const now = dateToTimestamp("2025-01-01T00:00:00Z"); const result = calculateCutoffTimestampWithNow(9001, now); const expected = dateToTimestamp("2024-01-01T00:00:00Z"); assertEquals( result, expected, "9001 retention (Jan 1, 2025 00:00:00) - should cutoff at Jan 1, 2024" ); } // Test 14: Verify data from 2024 is kept throughout 2025 when using 9001 // Example: Log created on July 15, 2024 should be kept until Dec 31, 2025 { // Running in June 2025 const nowJune2025 = dateToTimestamp("2025-06-15T12:00:00Z"); const cutoffJune2025 = calculateCutoffTimestampWithNow( 9001, nowJune2025 ); const logFromJuly2024 = dateToTimestamp("2024-07-15T12:00:00Z"); // Log from July 2024 should be KEPT in June 2025 assertEquals( logFromJuly2024 >= cutoffJune2025, true, "Log from July 2024 should be kept in June 2025" ); // Running in January 2026 const nowJan2026 = dateToTimestamp("2026-01-15T12:00:00Z"); const cutoffJan2026 = calculateCutoffTimestampWithNow(9001, nowJan2026); // Log from July 2024 should be DELETED in January 2026 assertEquals( logFromJuly2024 < cutoffJan2026, true, "Log from July 2024 should be deleted in Jan 2026" ); } // Test 15: Verify the exact requirement - data from 2024 must be purged on December 31, 2025 // On Dec 31, 2025 (still 2025), data from 2024 should still exist // On Jan 1, 2026 (now 2026), data from 2024 can be deleted { const logFromMid2024 = dateToTimestamp("2024-06-15T12:00:00Z"); // Dec 31, 2025 23:59:59 - still 2025, log should be kept const nowDec31_2025 = dateToTimestamp("2025-12-31T23:59:59Z"); const cutoffDec31 = calculateCutoffTimestampWithNow( 9001, nowDec31_2025 ); assertEquals( logFromMid2024 >= cutoffDec31, true, "Log from mid-2024 should be kept on Dec 31, 2025" ); // Jan 1, 2026 00:00:00 - now 2026, log can be deleted const nowJan1_2026 = dateToTimestamp("2026-01-01T00:00:00Z"); const cutoffJan1 = calculateCutoffTimestampWithNow(9001, nowJan1_2026); assertEquals( logFromMid2024 < cutoffJan1, true, "Log from mid-2024 should be deleted on Jan 1, 2026" ); } console.log("All calculateCutoffTimestamp tests passed!"); } // Run all tests try { testCalculateCutoffTimestamp(); console.log("All tests passed successfully!"); } catch (error) { console.error("Test failed:", error); process.exit(1); } ================================================ FILE: server/lib/cleanupLogs.ts ================================================ import { db, orgs } from "@server/db"; import { cleanUpOldLogs as cleanUpOldAccessLogs } from "#dynamic/lib/logAccessAudit"; import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/logActionAudit"; import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit"; import { gt, or } from "drizzle-orm"; import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils"; import { build } from "@server/build"; export function initLogCleanupInterval() { if (build == "saas") { // skip log cleanup for saas builds return null; } return setInterval( async () => { const orgsToClean = await db .select({ orgId: orgs.orgId, settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction, settingsLogRetentionDaysAccess: orgs.settingsLogRetentionDaysAccess, settingsLogRetentionDaysRequest: orgs.settingsLogRetentionDaysRequest }) .from(orgs) .where( or( gt(orgs.settingsLogRetentionDaysAction, 0), gt(orgs.settingsLogRetentionDaysAccess, 0), gt(orgs.settingsLogRetentionDaysRequest, 0) ) ); // TODO: handle when there are multiple nodes doing this clearing using redis for (const org of orgsToClean) { const { orgId, settingsLogRetentionDaysAction, settingsLogRetentionDaysAccess, settingsLogRetentionDaysRequest } = org; if (settingsLogRetentionDaysAction > 0) { await cleanUpOldActionLogs( orgId, settingsLogRetentionDaysAction ); } if (settingsLogRetentionDaysAccess > 0) { await cleanUpOldAccessLogs( orgId, settingsLogRetentionDaysAccess ); } if (settingsLogRetentionDaysRequest > 0) { await cleanUpOldRequestLogs( orgId, settingsLogRetentionDaysRequest ); } } await cleanUpOldFingerprintSnapshots(365); }, 3 * 60 * 60 * 1000 ); // every 3 hours } export function calculateCutoffTimestamp(retentionDays: number): number { const now = Math.floor(Date.now() / 1000); if (retentionDays === 9001) { // Special case: data is erased at the end of the year following the year it was generated // This means we delete logs from 2 years ago or older (logs from year Y are deleted after Dec 31 of year Y+1) const currentYear = new Date().getFullYear(); // Cutoff is the start of the year before last (Jan 1, currentYear - 1 at 00:00:00) // Any logs before this date are from 2+ years ago and should be deleted const cutoffDate = new Date(Date.UTC(currentYear - 1, 0, 1, 0, 0, 0)); return Math.floor(cutoffDate.getTime() / 1000); } else { return now - retentionDays * 24 * 60 * 60; } } ================================================ FILE: server/lib/clientVersionChecks.ts ================================================ import semver from "semver"; export function canCompress( clientVersion: string | null | undefined, type: "newt" | "olm" ): boolean { try { if (!clientVersion) return false; // check if it is a valid semver if (!semver.valid(clientVersion)) return false; if (type === "newt") { return semver.gte(clientVersion, "1.10.3"); } else if (type === "olm") { return semver.gte(clientVersion, "1.4.3"); } return false; } catch { return false; } } ================================================ FILE: server/lib/colorsSchema.ts ================================================ import { z } from "zod"; export const colorsSchema = z.object({ background: z.string().optional(), foreground: z.string().optional(), card: z.string().optional(), "card-foreground": z.string().optional(), popover: z.string().optional(), "popover-foreground": z.string().optional(), primary: z.string().optional(), "primary-foreground": z.string().optional(), secondary: z.string().optional(), "secondary-foreground": z.string().optional(), muted: z.string().optional(), "muted-foreground": z.string().optional(), accent: z.string().optional(), "accent-foreground": z.string().optional(), destructive: z.string().optional(), "destructive-foreground": z.string().optional(), border: z.string().optional(), input: z.string().optional(), ring: z.string().optional(), radius: z.string().optional(), "chart-1": z.string().optional(), "chart-2": z.string().optional(), "chart-3": z.string().optional(), "chart-4": z.string().optional(), "chart-5": z.string().optional() }); ================================================ FILE: server/lib/config.ts ================================================ import { z } from "zod"; import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; import { db } from "@server/db"; import { SupporterKey, supporterKey } from "@server/db"; import { eq } from "drizzle-orm"; import { configSchema, readConfigFile } from "./readConfigFile"; import { fromError } from "zod-validation-error"; import { build } from "@server/build"; export class Config { private rawConfig!: z.infer; supporterData: SupporterKey | null = null; supporterHiddenUntil: number | null = null; isDev: boolean = process.env.ENVIRONMENT !== "prod"; constructor() { const environment = readConfigFile(); const { data: parsedConfig, success, error } = configSchema.safeParse(environment); if (!success) { const errors = fromError(error); throw new Error(`Invalid configuration file: ${errors}`); } if ( // @ts-ignore parsedConfig.users || process.env.USERS_SERVERADMIN_EMAIL || process.env.USERS_SERVERADMIN_PASSWORD ) { console.log( "WARNING: Your admin credentials are still in the config file or environment variables. This method of setting admin credentials is no longer supported. It is recommended to remove them." ); } process.env.APP_VERSION = APP_VERSION; process.env.NEXT_PORT = parsedConfig.server.next_port.toString(); process.env.SERVER_EXTERNAL_PORT = parsedConfig.server.external_port.toString(); process.env.SERVER_INTERNAL_PORT = parsedConfig.server.internal_port.toString(); process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED = parsedConfig.flags ?.require_email_verification ? "true" : "false"; process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.flags ?.allow_raw_resources ? "true" : "false"; process.env.SESSION_COOKIE_NAME = parsedConfig.server.session_cookie_name; process.env.EMAIL_ENABLED = parsedConfig.email ? "true" : "false"; process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.flags ?.disable_signup_without_invite ? "true" : "false"; process.env.DISABLE_USER_CREATE_ORG = parsedConfig.flags ?.disable_user_create_org ? "true" : "false"; process.env.RESOURCE_ACCESS_TOKEN_PARAM = parsedConfig.server.resource_access_token_param; process.env.RESOURCE_ACCESS_TOKEN_HEADERS_ID = parsedConfig.server.resource_access_token_headers.id; process.env.RESOURCE_ACCESS_TOKEN_HEADERS_TOKEN = parsedConfig.server.resource_access_token_headers.token; process.env.RESOURCE_SESSION_REQUEST_PARAM = parsedConfig.server.resource_session_request_param; process.env.DASHBOARD_URL = parsedConfig.app.dashboard_url; process.env.FLAGS_DISABLE_LOCAL_SITES = parsedConfig.flags ?.disable_local_sites ? "true" : "false"; process.env.FLAGS_DISABLE_BASIC_WIREGUARD_SITES = parsedConfig.flags ?.disable_basic_wireguard_sites ? "true" : "false"; process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS = parsedConfig.flags ?.disable_product_help_banners ? "true" : "false"; process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app .notifications.product_updates ? "true" : "false"; process.env.NEW_RELEASES_NOTIFICATION_ENABLED = parsedConfig.app .notifications.new_releases ? "true" : "false"; if (parsedConfig.server.maxmind_db_path) { process.env.MAXMIND_DB_PATH = parsedConfig.server.maxmind_db_path; } if (parsedConfig.server.maxmind_asn_path) { process.env.MAXMIND_ASN_PATH = parsedConfig.server.maxmind_asn_path; } process.env.DISABLE_ENTERPRISE_FEATURES = parsedConfig.flags ?.disable_enterprise_features ? "true" : "false"; this.rawConfig = parsedConfig; } public async initServer() { if (!this.rawConfig) { throw new Error("Config not loaded. Call load() first."); } await this.checkKeyStatus(); } private async checkKeyStatus() { if (build == "oss") { this.checkSupporterKey(); } } public getRawConfig() { return this.rawConfig; } public getNoReplyEmail(): string | undefined { return ( this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user ); } public getDomain(domainId: string) { if (!this.rawConfig.domains || !this.rawConfig.domains[domainId]) { return null; } return this.rawConfig.domains[domainId]; } public hideSupporterKey(days: number = 7) { const now = new Date().getTime(); if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) { return; } this.supporterHiddenUntil = now + 1000 * 60 * 60 * 24 * days; } public isSupporterKeyHidden() { const now = new Date().getTime(); if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) { return true; } return false; } public async checkSupporterKey() { const [key] = await db.select().from(supporterKey).limit(1); if (!key) { return; } const { key: licenseKey, githubUsername } = key; try { const response = await fetch( `https://api.fossorial.io/api/v1/license/validate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ licenseKey, githubUsername }) } ); if (!response.ok) { this.supporterData = key; return; } const data = await response.json(); if (!data.data.valid) { this.supporterData = { ...key, valid: false }; return; } this.supporterData = { ...key, tier: data.data.tier, valid: true }; // update the supporter key in the database await db .update(supporterKey) .set({ tier: data.data.tier || null, phrase: data.data.cutePhrase || null, valid: true }) .where(eq(supporterKey.keyId, key.keyId)); } catch (e) { this.supporterData = key; console.error("Failed to validate supporter key", e); } } public getSupporterData() { return this.supporterData; } } export const config = new Config(); export default config; ================================================ FILE: server/lib/consts.ts ================================================ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process export const APP_VERSION = "1.16.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); export const APP_PATH = path.join("config"); export const configFilePath1 = path.join(APP_PATH, "config.yml"); export const configFilePath2 = path.join(APP_PATH, "config.yaml"); export const privateConfigFilePath1 = path.join(APP_PATH, "privateConfig.yml"); ================================================ FILE: server/lib/corsWithLoginPage.ts ================================================ import { Request, Response, NextFunction } from "express"; import cors, { CorsOptions } from "cors"; import config from "@server/lib/config"; import logger from "@server/logger"; import { db, loginPage } from "@server/db"; import { eq } from "drizzle-orm"; async function isValidLoginPageDomain(host: string): Promise { try { const [result] = await db .select() .from(loginPage) .where(eq(loginPage.fullDomain, host)) .limit(1); const isValid = !!result; return isValid; } catch (error) { logger.error("Error checking loginPage domain:", error); return false; } } export function corsWithLoginPageSupport(corsConfig: any) { const options = { ...(corsConfig?.origins ? { origin: corsConfig.origins } : { origin: (origin: any, callback: any) => { callback(null, true); } }), ...(corsConfig?.methods && { methods: corsConfig.methods }), ...(corsConfig?.allowed_headers && { allowedHeaders: corsConfig.allowed_headers }), credentials: !(corsConfig?.credentials === false) }; return async (req: Request, res: Response, next: NextFunction) => { const originValidatedCorsConfig = { origin: async ( origin: string | undefined, callback: (err: Error | null, allow?: boolean) => void ) => { // If no origin (e.g., same-origin request), allow it if (!origin) { return callback(null, true); } const dashboardUrl = config.getRawConfig().app.dashboard_url; // If no dashboard_url is configured, allow all origins if (!dashboardUrl) { return callback(null, true); } // Check if origin matches dashboard URL const dashboardHost = new URL(dashboardUrl).host; const originHost = new URL(origin).host; if (originHost === dashboardHost) { return callback(null, true); } if ( corsConfig?.origins && corsConfig.origins.includes(origin) ) { return callback(null, true); } // If origin doesn't match dashboard URL, check if it's a valid loginPage domain const isValidDomain = await isValidLoginPageDomain(originHost); if (isValidDomain) { return callback(null, true); } // Origin is not valid return callback(null, false); }, methods: corsConfig?.methods, allowedHeaders: corsConfig?.allowed_headers, credentials: corsConfig?.credentials !== false } as CorsOptions; return cors(originValidatedCorsConfig)(req, res, next); }; } ================================================ FILE: server/lib/crypto.ts ================================================ import CryptoJS from "crypto-js"; export function encrypt(value: string, key: string): string { const ciphertext = CryptoJS.AES.encrypt(value, key).toString(); return ciphertext; } export function decrypt(encryptedValue: string, key: string): string { const bytes = CryptoJS.AES.decrypt(encryptedValue, key); const originalText = bytes.toString(CryptoJS.enc.Utf8); return originalText; } ================================================ FILE: server/lib/deleteOrg.ts ================================================ import { clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, domains, exitNodeOrgs, exitNodes, olms, orgDomains, orgs, remoteExitNodes, resources, sites, userOrgs } from "@server/db"; import { newts, newtSessions } from "@server/db"; 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"; 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[]; olmsToTerminate: string[]; }; /** * Deletes one organization and its related data. Returns ids for termination * messages; caller should call sendTerminationMessages with the result. * Throws if org not found. */ export async function deleteOrgById( orgId: string ): Promise { const [org] = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (!org) { throw createHttpError( HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found` ); } const orgSites = await db .select() .from(sites) .where(eq(sites.orgId, orgId)) .limit(1); const orgClients = await db .select() .from(clients) .where(eq(clients.orgId, orgId)); 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) { if (site.type == "wireguard") { await deletePeer(site.exitNodeId!, site.pubKey); } else if (site.type == "newt") { const [deletedNewt] = await trx .delete(newts) .where(eq(newts.siteId, site.siteId)) .returning(); if (deletedNewt) { deletedNewtIds.push(deletedNewt.newtId); await trx .delete(newtSessions) .where(eq(newtSessions.newtId, deletedNewt.newtId)); } } } logger.info(`Deleting site ${site.siteId}`); await trx.delete(sites).where(eq(sites.siteId, site.siteId)); } for (const client of orgClients) { const [olm] = await trx .select() .from(olms) .where(eq(olms.clientId, client.clientId)) .limit(1); if (olm) { olmsToTerminate.push(olm.olmId); } logger.info(`Deleting client ${client.clientId}`); await trx .delete(clients) .where(eq(clients.clientId, client.clientId)); await trx .delete(clientSiteResourcesAssociationsCache) .where( eq( clientSiteResourcesAssociationsCache.clientId, client.clientId ) ); await trx .delete(clientSitesAssociationsCache) .where( eq(clientSitesAssociationsCache.clientId, client.clientId) ); } await trx.delete(resources).where(eq(resources.orgId, orgId)); const allOrgDomains = await trx .select() .from(orgDomains) .innerJoin(domains, eq(orgDomains.domainId, domains.domainId)) .where( and( eq(orgDomains.orgId, orgId), eq(domains.configManaged, false) ) ); logger.info(`Found ${allOrgDomains.length} domains to delete`); const domainIdsToDelete: string[] = []; for (const orgDomain of allOrgDomains) { const domainId = orgDomain.domains.domainId; const [orgCount] = await trx .select({ count: count() }) .from(orgDomains) .where(eq(orgDomains.domainId, domainId)); logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`); if (orgCount.count === 1) { domainIdsToDelete.push(domainId); } } logger.info(`Found ${domainIdsToDelete.length} domains to delete`); if (domainIdsToDelete.length > 0) { await trx .delete(domains) .where(inArray(domains.domainId, domainIdsToDelete)); } 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 }; } export function sendTerminationMessages(result: DeleteOrgByIdResult): void { for (const newtId of result.deletedNewtIds) { sendToClient(newtId, { type: `newt/wg/terminate`, data: {} }).catch( (error) => { logger.error( "Failed to send termination message to newt:", error ); } ); } for (const olmId of result.olmsToTerminate) { sendTerminateClient(0, OlmErrorCodes.TERMINATED_REKEYED, olmId).catch( (error) => { logger.error( "Failed to send termination message to olm:", error ); } ); } } ================================================ FILE: server/lib/domainUtils.ts ================================================ import { db } from "@server/db"; import { domains, orgDomains } from "@server/db"; import { eq, and } from "drizzle-orm"; import { subdomainSchema } from "@server/lib/schemas"; import { fromError } from "zod-validation-error"; export type DomainValidationResult = | { success: true; fullDomain: string; subdomain: string | null; } | { success: false; error: string; }; /** * Validates a domain and constructs the full domain based on domain type and subdomain. * * @param domainId - The ID of the domain to validate * @param orgId - The organization ID to check domain access * @param subdomain - Optional subdomain to append (for ns and wildcard domains) * @returns DomainValidationResult with success status and either fullDomain/subdomain or error message */ export async function validateAndConstructDomain( domainId: string, orgId: string, subdomain?: string | null ): Promise { try { // Query domain with organization access check const [domainRes] = await db .select() .from(domains) .where(eq(domains.domainId, domainId)) .leftJoin( orgDomains, and( eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId) ) ); // Check if domain exists if (!domainRes || !domainRes.domains) { return { success: false, error: `Domain with ID ${domainId} not found` }; } // Check if organization has access to domain if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) { return { success: false, error: `Organization does not have access to domain with ID ${domainId}` }; } // Check if domain is verified if (!domainRes.domains.verified) { return { success: false, error: `Domain with ID ${domainId} is not verified` }; } // Construct full domain based on domain type let fullDomain = ""; let finalSubdomain = subdomain; if (domainRes.domains.type === "ns") { if (subdomain) { fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; } else { fullDomain = domainRes.domains.baseDomain; } } else if (domainRes.domains.type === "cname") { fullDomain = domainRes.domains.baseDomain; finalSubdomain = null; // CNAME domains don't use subdomains } else if (domainRes.domains.type === "wildcard") { if (subdomain !== undefined && subdomain !== null) { // Validate subdomain format for wildcard domains const parsedSubdomain = subdomainSchema.safeParse(subdomain); if (!parsedSubdomain.success) { return { success: false, error: fromError(parsedSubdomain.error).toString() }; } fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`; } else { fullDomain = domainRes.domains.baseDomain; } } // If the full domain equals the base domain, set subdomain to null if (fullDomain === domainRes.domains.baseDomain) { finalSubdomain = null; } // Convert to lowercase fullDomain = fullDomain.toLowerCase(); return { success: true, fullDomain, subdomain: finalSubdomain ?? null }; } catch (error) { return { success: false, error: `An error occurred while validating domain: ${error instanceof Error ? error.message : "Unknown error"}` }; } } ================================================ FILE: server/lib/encryption.ts ================================================ import crypto from "crypto"; export function encryptData(data: string, key: Buffer): string { const algorithm = "aes-256-gcm"; const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv(algorithm, key, iv); let encrypted = cipher.update(data, "utf8", "hex"); encrypted += cipher.final("hex"); const authTag = cipher.getAuthTag(); // Combine IV, auth tag, and encrypted data return iv.toString("hex") + ":" + authTag.toString("hex") + ":" + encrypted; } // Helper function to decrypt data (you'll need this to read certificates) export function decryptData(encryptedData: string, key: Buffer): string { const algorithm = "aes-256-gcm"; const parts = encryptedData.split(":"); if (parts.length !== 3) { throw new Error("Invalid encrypted data format"); } const iv = Buffer.from(parts[0], "hex"); const authTag = Buffer.from(parts[1], "hex"); const encrypted = parts[2]; const decipher = crypto.createDecipheriv(algorithm, key, iv); decipher.setAuthTag(authTag); let decrypted = decipher.update(encrypted, "hex", "utf8"); decrypted += decipher.final("utf8"); return decrypted; } // openssl rand -hex 32 > config/encryption.key ================================================ FILE: server/lib/exitNodes/exitNodeComms.ts ================================================ import axios from "axios"; import logger from "@server/logger"; import { ExitNode } from "@server/db"; interface ExitNodeRequest { remoteType?: string; localPath: string; method?: "POST" | "DELETE" | "GET" | "PUT"; data?: any; queryParams?: Record; } /** * Sends a request to an exit node, handling both remote and local exit nodes * @param exitNode The exit node to send the request to * @param request The request configuration * @returns Promise Response data for local nodes, undefined for remote nodes */ export async function sendToExitNode( exitNode: ExitNode, request: ExitNodeRequest ): Promise { if (!exitNode.reachableAt) { throw new Error( `Exit node with ID ${exitNode.exitNodeId} is not reachable` ); } // Handle local exit node with HTTP API const method = request.method || "POST"; let url = `${exitNode.reachableAt}${request.localPath}`; // Add query parameters if provided if (request.queryParams) { const params = new URLSearchParams(request.queryParams); url += `?${params.toString()}`; } try { let response; switch (method) { case "POST": response = await axios.post(url, request.data, { headers: { "Content-Type": "application/json" } }); break; case "DELETE": response = await axios.delete(url); break; case "GET": response = await axios.get(url); break; case "PUT": response = await axios.put(url, request.data, { headers: { "Content-Type": "application/json" } }); break; default: throw new Error(`Unsupported HTTP method: ${method}`); } logger.info(`Exit node request successful:`, { method, url, status: response.data.status }); return response.data; } catch (error) { if (axios.isAxiosError(error)) { logger.error( `Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}` ); } else { logger.error( `Error making ${method} request for exit node at ${exitNode.reachableAt}: ${error}` ); } throw error; } } ================================================ FILE: server/lib/exitNodes/exitNodes.ts ================================================ import { db, exitNodes, Transaction } from "@server/db"; import logger from "@server/logger"; import { ExitNodePingResult } from "@server/routers/newt"; import { eq } from "drizzle-orm"; export async function verifyExitNodeOrgAccess( exitNodeId: number, orgId: string ) { const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, exitNodeId)); // For any other type, deny access return { hasAccess: true, exitNode }; } export async function listExitNodes( orgId: string, filterOnline = false, noCloud = false ) { // TODO: pick which nodes to send and ping better than just all of them that are not remote const allExitNodes = await db .select({ exitNodeId: exitNodes.exitNodeId, name: exitNodes.name, address: exitNodes.address, endpoint: exitNodes.endpoint, publicKey: exitNodes.publicKey, listenPort: exitNodes.listenPort, reachableAt: exitNodes.reachableAt, maxConnections: exitNodes.maxConnections, online: exitNodes.online, lastPing: exitNodes.lastPing, type: exitNodes.type, region: exitNodes.region }) .from(exitNodes); // Filter the nodes. If there are NO remoteExitNodes then do nothing. If there are then remove all of the non-remoteExitNodes if (allExitNodes.length === 0) { logger.warn("No exit nodes found!"); return []; } return allExitNodes; } export function selectBestExitNode( pingResults: ExitNodePingResult[] ): ExitNodePingResult | null { if (!pingResults || pingResults.length === 0) { logger.warn("No ping results provided"); return null; } return pingResults[0]; } export async function checkExitNodeOrg( exitNodeId: number, orgId: string, trx?: Transaction | typeof db ): Promise { return false; } export async function resolveExitNodes( hostname: string, publicKey: string ): Promise< { endpoint: string; publicKey: string; orgId: string; }[] > { // OSS version: simple implementation that returns empty array return []; } ================================================ FILE: server/lib/exitNodes/getCurrentExitNodeId.ts ================================================ import { eq } from "drizzle-orm"; import { db, exitNodes } from "@server/db"; import config from "@server/lib/config"; let currentExitNodeId: number; // we really only need to look this up once per instance export async function getCurrentExitNodeId(): Promise { if (!currentExitNodeId) { if (config.getRawConfig().gerbil.exit_node_name) { const exitNodeName = config.getRawConfig().gerbil.exit_node_name!; const [exitNode] = await db .select({ exitNodeId: exitNodes.exitNodeId }) .from(exitNodes) .where(eq(exitNodes.name, exitNodeName)); if (exitNode) { currentExitNodeId = exitNode.exitNodeId; } } else { const [exitNode] = await db .select({ exitNodeId: exitNodes.exitNodeId }) .from(exitNodes) .limit(1); if (exitNode) { currentExitNodeId = exitNode.exitNodeId; } } } return currentExitNodeId; } ================================================ FILE: server/lib/exitNodes/index.ts ================================================ export * from "./exitNodes"; export * from "./exitNodeComms"; export * from "./subnet"; export * from "./getCurrentExitNodeId"; ================================================ FILE: server/lib/exitNodes/subnet.ts ================================================ import { db, exitNodes } from "@server/db"; import config from "@server/lib/config"; import { findNextAvailableCidr } from "@server/lib/ip"; export async function getNextAvailableSubnet(): Promise { // Get all existing subnets from routes table const existingAddresses = await db .select({ address: exitNodes.address }) .from(exitNodes); const addresses = existingAddresses.map((a) => a.address); let subnet = findNextAvailableCidr( addresses, config.getRawConfig().gerbil.block_size, config.getRawConfig().gerbil.subnet_group ); if (!subnet) { throw new Error("No available subnets remaining in space"); } // replace the last octet with 1 subnet = subnet.split(".").slice(0, 3).join(".") + ".1" + "/" + subnet.split("/")[1]; return subnet; } ================================================ FILE: server/lib/geoip.ts ================================================ import logger from "@server/logger"; import { maxmindLookup } from "@server/db/maxmind"; export async function getCountryCodeForIp( ip: string ): Promise { try { if (!maxmindLookup) { logger.debug( "MaxMind DB path not configured, cannot perform GeoIP lookup" ); return; } const result = maxmindLookup.get(ip); if (!result || !result.country) { return; } const { country } = result; logger.debug( `GeoIP lookup successful for IP ${ip}: ${country.iso_code}` ); return country.iso_code; } catch (error) { logger.error("Error fetching config in verify session:", error); } return; } ================================================ FILE: server/lib/getEnvOrYaml.ts ================================================ export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => { return process.env[envVar] ?? valFromYaml; }; ================================================ FILE: server/lib/hostMeta.ts ================================================ import { db, HostMeta } from "@server/db"; import { hostMeta } from "@server/db"; import { v4 as uuidv4 } from "uuid"; let gotHostMeta: HostMeta | undefined; export async function setHostMeta() { const [existing] = await db.select().from(hostMeta).limit(1); if (existing && existing.hostMetaId) { return; } const id = uuidv4(); await db .insert(hostMeta) .values({ hostMetaId: id, createdAt: new Date().getTime() }); } export async function getHostMeta() { if (gotHostMeta) { return gotHostMeta; } const [meta] = await db.select().from(hostMeta).limit(1); gotHostMeta = meta; return meta; } ================================================ FILE: server/lib/idp/generateRedirectUrl.ts ================================================ import { db, loginPage, loginPageOrg } from "@server/db"; import config from "@server/lib/config"; import { eq } from "drizzle-orm"; export async function generateOidcRedirectUrl( idpId: number, orgId?: string, loginPageId?: number ): Promise { let baseUrl: string | undefined; const secure = config.getRawConfig().app.dashboard_url?.startsWith("https"); const method = secure ? "https" : "http"; if (loginPageId) { const [res] = await db .select() .from(loginPage) .where(eq(loginPage.loginPageId, loginPageId)) .limit(1); if (res && res.fullDomain) { baseUrl = `${method}://${res.fullDomain}`; } } else if (orgId) { const [res] = await db .select() .from(loginPageOrg) .where(eq(loginPageOrg.orgId, orgId)) .innerJoin( loginPage, eq(loginPage.loginPageId, loginPageOrg.loginPageId) ) .limit(1); if ( res?.loginPage && res.loginPage.domainId && res.loginPage.fullDomain ) { baseUrl = `${method}://${res.loginPage.fullDomain}`; } } if (!baseUrl) { baseUrl = config.getRawConfig().app.dashboard_url!; } const redirectPath = `/auth/idp/${idpId}/oidc/callback`; const redirectUrl = new URL(redirectPath, baseUrl!).toString(); return redirectUrl; } ================================================ FILE: server/lib/ip.test.ts ================================================ import { cidrToRange, findNextAvailableCidr } from "./ip"; import { assertEquals } from "@test/assert"; // Test cases function testFindNextAvailableCidr() { console.log("Running findNextAvailableCidr tests..."); // Test 0: Basic IPv4 allocation with a subnet in the wrong range { const existing = ["100.90.130.1/30", "100.90.128.4/30"]; const result = findNextAvailableCidr(existing, 30, "100.90.130.1/24"); assertEquals(result, "100.90.130.4/30", "Basic IPv4 allocation failed"); } // Test 1: Basic IPv4 allocation { const existing = ["10.0.0.0/16", "10.1.0.0/16"]; const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8"); assertEquals(result, "10.2.0.0/16", "Basic IPv4 allocation failed"); } // Test 2: Finding gap between allocations { const existing = ["10.0.0.0/16", "10.2.0.0/16"]; const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8"); assertEquals( result, "10.1.0.0/16", "Finding gap between allocations failed" ); } // Test 3: No available space { const existing = ["10.0.0.0/8"]; const result = findNextAvailableCidr(existing, 8, "10.0.0.0/8"); assertEquals(result, null, "No available space test failed"); } // Test 4: Empty existing { const existing: string[] = []; const result = findNextAvailableCidr(existing, 30, "10.0.0.0/8"); assertEquals(result, "10.0.0.0/30", "Empty existing test failed"); } // // Test 4: IPv6 allocation // { // const existing = ["2001:db8::/32", "2001:db8:1::/32"]; // const result = findNextAvailableCidr(existing, 32, "2001:db8::/16"); // assertEquals(result, "2001:db8:2::/32", "Basic IPv6 allocation failed"); // } // // Test 5: Mixed IP versions // { // const existing = ["10.0.0.0/16", "2001:db8::/32"]; // assertThrows( // () => findNextAvailableCidr(existing, 16), // "All CIDRs must be of the same IP version", // "Mixed IP versions test failed" // ); // } // Test 6: Empty input { const existing: string[] = []; const result = findNextAvailableCidr(existing, 16); assertEquals(result, null, "Empty input test failed"); } // Test 7: Block size alignment { const existing = ["10.0.0.0/24"]; const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16"); assertEquals(result, "10.0.1.0/24", "Block size alignment test failed"); } // Test 8: Block size alignment { const existing: string[] = []; const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16"); assertEquals(result, "10.0.0.0/24", "Block size alignment test failed"); } // Test 9: Large block size request { const existing = ["10.0.0.0/24", "10.0.1.0/24"]; const result = findNextAvailableCidr(existing, 16, "10.0.0.0/16"); assertEquals(result, null, "Large block size request test failed"); } console.log("All findNextAvailableCidr tests passed!"); } // function testCidrToRange() { // console.log("Running cidrToRange tests..."); // // Test 1: Basic IPv4 conversion // { // const result = cidrToRange("192.168.0.0/24"); // assertEqualsObj(result, { // start: BigInt("3232235520"), // end: BigInt("3232235775") // }, "Basic IPv4 conversion failed"); // } // // Test 2: IPv6 conversion // { // const result = cidrToRange("2001:db8::/32"); // assertEqualsObj(result, { // start: BigInt("42540766411282592856903984951653826560"), // end: BigInt("42540766411282592875350729025363378175") // }, "IPv6 conversion failed"); // } // // Test 3: Invalid prefix length // { // assertThrows( // () => cidrToRange("192.168.0.0/33"), // "Invalid prefix length for IPv4", // "Invalid IPv4 prefix test failed" // ); // } // // Test 4: Invalid IPv6 prefix // { // assertThrows( // () => cidrToRange("2001:db8::/129"), // "Invalid prefix length for IPv6", // "Invalid IPv6 prefix test failed" // ); // } // console.log("All cidrToRange tests passed!"); // } // Run all tests try { // testCidrToRange(); testFindNextAvailableCidr(); console.log("All tests passed successfully!"); } catch (error) { console.error("Test failed:", error); process.exit(1); } ================================================ FILE: server/lib/ip.ts ================================================ import { db, SiteResource, siteResources, Transaction } from "@server/db"; import { clients, orgs, sites } from "@server/db"; import { and, eq, isNotNull } from "drizzle-orm"; import config from "@server/lib/config"; import z from "zod"; import logger from "@server/logger"; import semver from "semver"; interface IPRange { start: bigint; end: bigint; } type IPVersion = 4 | 6; /** * Detects IP version from address string */ function detectIpVersion(ip: string): IPVersion { return ip.includes(":") ? 6 : 4; } /** * Converts IPv4 or IPv6 address string to BigInt for numerical operations */ function ipToBigInt(ip: string): bigint { const version = detectIpVersion(ip); if (version === 4) { return ip.split(".").reduce((acc, octet) => { const num = parseInt(octet); if (isNaN(num) || num < 0 || num > 255) { throw new Error(`Invalid IPv4 octet: ${octet}`); } return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num)); }, BigInt(0)); } else { // Handle IPv6 // Expand :: notation let fullAddress = ip; if (ip.includes("::")) { const parts = ip.split("::"); if (parts.length > 2) throw new Error("Invalid IPv6 address: multiple :: found"); const missing = 8 - (parts[0].split(":").length + parts[1].split(":").length); const padding = Array(missing).fill("0").join(":"); fullAddress = `${parts[0]}:${padding}:${parts[1]}`; } return fullAddress.split(":").reduce((acc, hextet) => { const num = parseInt(hextet || "0", 16); if (isNaN(num) || num < 0 || num > 65535) { throw new Error(`Invalid IPv6 hextet: ${hextet}`); } return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num)); }, BigInt(0)); } } /** * Converts BigInt to IP address string */ function bigIntToIp(num: bigint, version: IPVersion): string { if (version === 4) { const octets: number[] = []; for (let i = 0; i < 4; i++) { octets.unshift(Number(num & BigInt(255))); num = num >> BigInt(8); } return octets.join("."); } else { const hextets: string[] = []; for (let i = 0; i < 8; i++) { hextets.unshift( Number(num & BigInt(65535)) .toString(16) .padStart(4, "0") ); num = num >> BigInt(16); } // Compress zero sequences let maxZeroStart = -1; let maxZeroLength = 0; let currentZeroStart = -1; let currentZeroLength = 0; for (let i = 0; i < hextets.length; i++) { if (hextets[i] === "0000") { if (currentZeroStart === -1) currentZeroStart = i; currentZeroLength++; if (currentZeroLength > maxZeroLength) { maxZeroLength = currentZeroLength; maxZeroStart = currentZeroStart; } } else { currentZeroStart = -1; currentZeroLength = 0; } } if (maxZeroLength > 1) { hextets.splice(maxZeroStart, maxZeroLength, ""); if (maxZeroStart === 0) hextets.unshift(""); if (maxZeroStart + maxZeroLength === 8) hextets.push(""); } return hextets .map((h) => (h === "0000" ? "0" : h.replace(/^0+/, ""))) .join(":"); } } /** * Parses an endpoint string (ip:port) handling both IPv4 and IPv6 addresses. * IPv6 addresses may be bracketed like [::1]:8080 or unbracketed like ::1:8080. * For unbracketed IPv6, the last colon-separated segment is treated as the port. * * @param endpoint The endpoint string to parse (e.g., "192.168.1.1:8080" or "[::1]:8080" or "2607:fea8::1:8080") * @returns An object with ip and port, or null if parsing fails */ export function parseEndpoint( endpoint: string ): { ip: string; port: number } | null { if (!endpoint) return null; // Check for bracketed IPv6 format: [ip]:port const bracketedMatch = endpoint.match(/^\[([^\]]+)\]:(\d+)$/); if (bracketedMatch) { const ip = bracketedMatch[1]; const port = parseInt(bracketedMatch[2], 10); if (isNaN(port)) return null; return { ip, port }; } // Check if this looks like IPv6 (contains multiple colons) const colonCount = (endpoint.match(/:/g) || []).length; if (colonCount > 1) { // This is IPv6 - the port is after the last colon const lastColonIndex = endpoint.lastIndexOf(":"); const ip = endpoint.substring(0, lastColonIndex); const portStr = endpoint.substring(lastColonIndex + 1); const port = parseInt(portStr, 10); if (isNaN(port)) return null; return { ip, port }; } // IPv4 format: ip:port if (colonCount === 1) { const [ip, portStr] = endpoint.split(":"); const port = parseInt(portStr, 10); if (isNaN(port)) return null; return { ip, port }; } return null; } /** * Formats an IP and port into a consistent endpoint string. * IPv6 addresses are wrapped in brackets for proper parsing. * * @param ip The IP address (IPv4 or IPv6) * @param port The port number * @returns Formatted endpoint string */ export function formatEndpoint(ip: string, port: number): string { // Check if this is IPv6 (contains colons) if (ip.includes(":")) { // Remove brackets if already present const cleanIp = ip.replace(/^\[|\]$/g, ""); return `[${cleanIp}]:${port}`; } return `${ip}:${port}`; } /** * Converts CIDR to IP range */ export function cidrToRange(cidr: string): IPRange { const [ip, prefix] = cidr.split("/"); const version = detectIpVersion(ip); const prefixBits = parseInt(prefix); const ipBigInt = ipToBigInt(ip); // Validate prefix length const maxPrefix = version === 4 ? 32 : 128; if (prefixBits < 0 || prefixBits > maxPrefix) { throw new Error(`Invalid prefix length for IPv${version}: ${prefix}`); } const shiftBits = BigInt(maxPrefix - prefixBits); const mask = BigInt.asUintN( version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1) ); const start = ipBigInt & ~mask; const end = start | mask; return { start, end }; } /** * Finds the next available CIDR block given existing allocations * @param existingCidrs Array of existing CIDR blocks * @param blockSize Desired prefix length for the new block * @param startCidr Optional CIDR to start searching from * @returns Next available CIDR block or null if none found */ export function findNextAvailableCidr( existingCidrs: string[], blockSize: number, startCidr?: string ): string | null { if (!startCidr && existingCidrs.length === 0) { return null; } // If no existing CIDRs, use the IP version from startCidr const version = startCidr ? detectIpVersion(startCidr.split("/")[0]) : 4; // Default to IPv4 if no startCidr provided // Use appropriate default startCidr if none provided startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0"); // If there are existing CIDRs, ensure all are same version if ( existingCidrs.length > 0 && existingCidrs.some( (cidr) => detectIpVersion(cidr.split("/")[0]) !== version ) ) { throw new Error("All CIDRs must be of the same IP version"); } // Extract the network part from startCidr to ensure we stay in the right subnet const startCidrRange = cidrToRange(startCidr); // Convert existing CIDRs to ranges and sort them const existingRanges = existingCidrs .map((cidr) => cidrToRange(cidr)) .sort((a, b) => (a.start < b.start ? -1 : 1)); // Calculate block size const maxPrefix = version === 4 ? 32 : 128; const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize); // Start from the beginning of the given CIDR let current = startCidrRange.start; const maxIp = startCidrRange.end; // Iterate through existing ranges for (let i = 0; i <= existingRanges.length; i++) { const nextRange = existingRanges[i]; // Align current to block size const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt); // Check if we've gone beyond the maximum allowed IP if (alignedCurrent + blockSizeBigInt - BigInt(1) > maxIp) { return null; } // If we're at the end of existing ranges or found a gap if ( !nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start ) { return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`; } // If next range overlaps with our search space, move past it if (nextRange.end >= startCidrRange.start && nextRange.start <= maxIp) { // Move current pointer to after the current range current = nextRange.end + BigInt(1); } } return null; } /** * Checks if a given IP address is within a CIDR range * @param ip IP address to check * @param cidr CIDR range to check against * @returns boolean indicating if IP is within the CIDR range */ export function isIpInCidr(ip: string, cidr: string): boolean { const ipVersion = detectIpVersion(ip); const cidrVersion = detectIpVersion(cidr.split("/")[0]); // If IP versions don't match, the IP cannot be in the CIDR range if (ipVersion !== cidrVersion) { // throw new Erorr return false; } const ipBigInt = ipToBigInt(ip); const range = cidrToRange(cidr); return ipBigInt >= range.start && ipBigInt <= range.end; } /** * Checks if two CIDR ranges overlap * @param cidr1 First CIDR string * @param cidr2 Second CIDR string * @returns boolean indicating if the two CIDRs overlap */ export function doCidrsOverlap(cidr1: string, cidr2: string): boolean { const version1 = detectIpVersion(cidr1.split("/")[0]); const version2 = detectIpVersion(cidr2.split("/")[0]); if (version1 !== version2) { // Different IP versions cannot overlap return false; } const range1 = cidrToRange(cidr1); const range2 = cidrToRange(cidr2); // Overlap if the ranges intersect return range1.start <= range2.end && range2.start <= range1.end; } export async function getNextAvailableClientSubnet( orgId: string, transaction: Transaction | typeof db = db ): Promise { const [org] = await transaction .select() .from(orgs) .where(eq(orgs.orgId, orgId)); if (!org) { throw new Error(`Organization with ID ${orgId} not found`); } if (!org.subnet) { throw new Error(`Organization with ID ${orgId} has no subnet defined`); } const existingAddressesSites = await transaction .select({ address: sites.address }) .from(sites) .where(and(isNotNull(sites.address), eq(sites.orgId, orgId))); const existingAddressesClients = await transaction .select({ address: clients.subnet }) .from(clients) .where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId))); const addresses = [ ...existingAddressesSites.map( (site) => `${site.address?.split("/")[0]}/32` ), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org ...existingAddressesClients.map( (client) => `${client.address.split("/")}/32` ) ].filter((address) => address !== null) as string[]; const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org if (!subnet) { throw new Error("No available subnets remaining in space"); } return subnet; } export async function getNextAvailableAliasAddress( orgId: string ): Promise { const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); if (!org) { throw new Error(`Organization with ID ${orgId} not found`); } if (!org.subnet) { throw new Error(`Organization with ID ${orgId} has no subnet defined`); } if (!org.utilitySubnet) { throw new Error( `Organization with ID ${orgId} has no utility subnet defined` ); } const existingAddresses = await db .select({ aliasAddress: siteResources.aliasAddress }) .from(siteResources) .where( and( isNotNull(siteResources.aliasAddress), eq(siteResources.orgId, orgId) ) ); const addresses = [ ...existingAddresses.map( (site) => `${site.aliasAddress?.split("/")[0]}/32` ), // reserve a /29 for the dns server and other stuff `${org.utilitySubnet.split("/")[0]}/29` ].filter((address) => address !== null) as string[]; let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet); if (!subnet) { throw new Error("No available subnets remaining in space"); } // remove the cidr subnet = subnet.split("/")[0]; return subnet; } export async function getNextAvailableOrgSubnet(): Promise { const existingAddresses = await db .select({ subnet: orgs.subnet }) .from(orgs) .where(isNotNull(orgs.subnet)); const addresses = existingAddresses.map((org) => org.subnet!); const subnet = findNextAvailableCidr( addresses, config.getRawConfig().orgs.block_size, config.getRawConfig().orgs.subnet_group ); if (!subnet) { throw new Error("No available subnets remaining in space"); } return subnet; } export function generateRemoteSubnets( allSiteResources: SiteResource[] ): string[] { const remoteSubnets = allSiteResources .filter((sr) => { if (sr.mode === "cidr") { // check if its a valid CIDR using zod const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]); const parseResult = cidrSchema.safeParse(sr.destination); return parseResult.success; } if (sr.mode === "host") { // check if its a valid IP using zod const ipSchema = z.union([z.ipv4(), z.ipv6()]); const parseResult = ipSchema.safeParse(sr.destination); return parseResult.success; } return false; }) .map((sr) => { if (sr.mode === "cidr") return sr.destination; if (sr.mode === "host") { return `${sr.destination}/32`; } return ""; // This should never be reached due to filtering, but satisfies TypeScript }) .filter((subnet) => subnet !== ""); // Remove empty strings just to be safe // remove duplicates return Array.from(new Set(remoteSubnets)); } export type Alias = { alias: string | null; aliasAddress: string | null }; export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { return allSiteResources .filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host") .map((sr) => ({ alias: sr.alias, aliasAddress: sr.aliasAddress })); } export type SubnetProxyTarget = { sourcePrefix: string; // must be a cidr destPrefix: string; // must be a cidr disableIcmp?: boolean; rewriteTo?: string; // must be a cidr portRange?: { min: number; max: number; protocol: "tcp" | "udp"; }[]; }; export function generateSubnetProxyTargets( siteResource: SiteResource, clients: { clientId: number; pubKey: string | null; subnet: string | null; }[] ): SubnetProxyTarget[] { const targets: SubnetProxyTarget[] = []; if (clients.length === 0) { logger.debug( `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` ); return []; } for (const clientSite of clients) { if (!clientSite.subnet) { logger.debug( `Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.` ); continue; } const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`; const portRange = [ ...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"), ...parsePortRangeString(siteResource.udpPortRangeString, "udp") ]; const disableIcmp = siteResource.disableIcmp ?? false; if (siteResource.mode == "host") { let destination = siteResource.destination; // check if this is a valid ip const ipSchema = z.union([z.ipv4(), z.ipv6()]); if (ipSchema.safeParse(destination).success) { destination = `${destination}/32`; targets.push({ sourcePrefix: clientPrefix, destPrefix: destination, portRange, disableIcmp }); } if (siteResource.alias && siteResource.aliasAddress) { // also push a match for the alias address targets.push({ sourcePrefix: clientPrefix, destPrefix: `${siteResource.aliasAddress}/32`, rewriteTo: destination, portRange, disableIcmp }); } } else if (siteResource.mode == "cidr") { targets.push({ sourcePrefix: clientPrefix, destPrefix: siteResource.destination, portRange, disableIcmp }); } } // print a nice representation of the targets // logger.debug( // `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}` // ); return targets; } // Custom schema for validating port range strings // Format: "80,443,8000-9000" or "*" for all ports, or empty string export const portRangeStringSchema = z .string() .optional() .refine( (val) => { if (!val || val.trim() === "" || val.trim() === "*") { return true; } // Split by comma and validate each part const parts = val.split(",").map((p) => p.trim()); for (const part of parts) { if (part === "") { return false; // empty parts not allowed } // Check if it's a range (contains dash) if (part.includes("-")) { const [start, end] = part.split("-").map((p) => p.trim()); // Both parts must be present if (!start || !end) { return false; } const startPort = parseInt(start, 10); const endPort = parseInt(end, 10); // Must be valid numbers if (isNaN(startPort) || isNaN(endPort)) { return false; } // Must be valid port range (1-65535) if ( startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535 ) { return false; } // Start must be <= end if (startPort > endPort) { return false; } } else { // Single port const port = parseInt(part, 10); // Must be a valid number if (isNaN(port)) { return false; } // Must be valid port range (1-65535) if (port < 1 || port > 65535) { return false; } } } return true; }, { message: '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, and ranges must have start <= end.' } ); /** * Parses a port range string into an array of port range objects * @param portRangeStr - Port range string (e.g., "80,443,8000-9000", "*", or "") * @param protocol - Protocol to use for all ranges (default: "tcp") * @returns Array of port range objects with min, max, and protocol fields */ export function parsePortRangeString( portRangeStr: string | undefined | null, protocol: "tcp" | "udp" = "tcp" ): { min: number; max: number; protocol: "tcp" | "udp" }[] { // Handle undefined or empty string - insert dummy value with port 0 if (!portRangeStr || portRangeStr.trim() === "") { return [{ min: 0, max: 0, protocol }]; } // Handle wildcard - return empty array (all ports allowed) if (portRangeStr.trim() === "*") { return []; } const result: { min: number; max: number; protocol: "tcp" | "udp" }[] = []; const parts = portRangeStr.split(",").map((p) => p.trim()); for (const part of parts) { if (part.includes("-")) { // Range const [start, end] = part.split("-").map((p) => p.trim()); const startPort = parseInt(start, 10); const endPort = parseInt(end, 10); result.push({ min: startPort, max: endPort, protocol }); } else { // Single port const port = parseInt(part, 10); result.push({ min: port, max: port, protocol }); } } return result; } export function stripPortFromHost(ip: string, badgerVersion?: string): string { const isNewerBadger = badgerVersion && semver.valid(badgerVersion) && semver.gte(badgerVersion, "1.3.1"); if (isNewerBadger) { return ip; } if (ip.startsWith("[") && ip.includes("]")) { // if brackets are found, extract the IPv6 address from between the brackets const ipv6Match = ip.match(/\[(.*?)\]/); if (ipv6Match) { return ipv6Match[1]; } } // Check if it looks like IPv4 (contains dots and matches IPv4 pattern) // IPv4 format: x.x.x.x where x is 0-255 const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/; if (ipv4Pattern.test(ip)) { const lastColonIndex = ip.lastIndexOf(":"); if (lastColonIndex !== -1) { return ip.substring(0, lastColonIndex); } } // Return as is return ip; } ================================================ FILE: server/lib/isLicencedOrSubscribed.ts ================================================ import { Tier } from "@server/types/Tiers"; export async function isLicensedOrSubscribed( orgId: string, tiers: Tier[] ): Promise { return false; } ================================================ FILE: server/lib/isSubscribed.ts ================================================ import { Tier } from "@server/types/Tiers"; export async function isSubscribed( orgId: string, tiers: Tier[] ): Promise { return false; } ================================================ FILE: server/lib/lock.ts ================================================ export class LockManager { /** * Acquire a distributed lock using Redis SET with NX and PX options * @param lockKey - Unique identifier for the lock * @param ttlMs - Time to live in milliseconds * @returns Promise - true if lock acquired, false otherwise */ async acquireLock( lockKey: string, ttlMs: number = 30000 ): Promise { return true; } /** * Release a lock using Lua script to ensure atomicity * @param lockKey - Unique identifier for the lock */ async releaseLock(lockKey: string): Promise {} /** * Force release a lock regardless of owner (use with caution) * @param lockKey - Unique identifier for the lock */ async forceReleaseLock(lockKey: string): Promise {} /** * Check if a lock exists and get its info * @param lockKey - Unique identifier for the lock * @returns Promise<{exists: boolean, ownedByMe: boolean, ttl: number}> */ async getLockInfo(lockKey: string): Promise<{ exists: boolean; ownedByMe: boolean; ttl: number; owner?: string; }> { return { exists: true, ownedByMe: true, ttl: 0 }; } /** * Extend the TTL of an existing lock owned by this worker * @param lockKey - Unique identifier for the lock * @param ttlMs - New TTL in milliseconds * @returns Promise - true if extended successfully */ async extendLock(lockKey: string, ttlMs: number): Promise { return true; } /** * Attempt to acquire lock with retries and exponential backoff * @param lockKey - Unique identifier for the lock * @param ttlMs - Time to live in milliseconds * @param maxRetries - Maximum number of retry attempts * @param baseDelayMs - Base delay between retries in milliseconds * @returns Promise - true if lock acquired */ async acquireLockWithRetry( lockKey: string, ttlMs: number = 30000, maxRetries: number = 5, baseDelayMs: number = 100 ): Promise { return true; } /** * Execute a function while holding a lock * @param lockKey - Unique identifier for the lock * @param fn - Function to execute while holding the lock * @param ttlMs - Lock TTL in milliseconds * @returns Promise - Result of the executed function */ async withLock( lockKey: string, fn: () => Promise, ttlMs: number = 30000 ): Promise { const acquired = await this.acquireLock(lockKey, ttlMs); if (!acquired) { throw new Error(`Failed to acquire lock: ${lockKey}`); } try { return await fn(); } finally { await this.releaseLock(lockKey); } } /** * Clean up expired locks - Redis handles this automatically, but this method * can be used to get statistics about locks * @returns Promise<{activeLocksCount: number, locksOwnedByMe: number}> */ async getLockStatistics(): Promise<{ activeLocksCount: number; locksOwnedByMe: number; }> { return { activeLocksCount: 0, locksOwnedByMe: 0 }; } /** * Close the Redis connection */ async disconnect(): Promise {} } export const lockManager = new LockManager(); ================================================ FILE: server/lib/logAccessAudit.ts ================================================ export async function cleanUpOldLogs(orgId: string, retentionDays: number) { return; } export async function logAccessAudit(data: { action: boolean; type: string; orgId: string; resourceId?: number; user?: { username: string; userId: string }; apiKey?: { name: string | null; apiKeyId: string }; metadata?: any; userAgent?: string; requestIp?: string; }) { return; } ================================================ FILE: server/lib/normalizePostAuthPath.ts ================================================ /** * Normalizes a post-authentication path for safe use when building redirect URLs. * Returns a path that starts with / and does not allow open redirects (no //, no :). */ export function normalizePostAuthPath(path: string | null | undefined): string | null { if (path == null || typeof path !== "string") { return null; } const trimmed = path.trim(); if (trimmed === "") { return null; } // Reject protocol-relative (//) or scheme (:) to avoid open redirect if (trimmed.includes("//") || trimmed.includes(":")) { return null; } return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; } ================================================ FILE: server/lib/rateLimitStore.ts ================================================ import { MemoryStore, Store } from "express-rate-limit"; export function createStore(): Store { const rateLimitStore: Store = new MemoryStore(); return rateLimitStore; } ================================================ FILE: server/lib/readConfigFile.ts ================================================ import fs from "fs"; import yaml from "js-yaml"; import { configFilePath1, configFilePath2 } from "./consts"; import { z } from "zod"; import stoi from "./stoi"; import { getEnvOrYaml } from "./getEnvOrYaml"; const portSchema = z.number().positive().gt(0).lte(65535); export const configSchema = z .object({ app: z .object({ dashboard_url: z .url() .pipe(z.url()) .transform((url) => url.toLowerCase()) .optional(), log_level: z .enum(["debug", "info", "warn", "error"]) .optional() .default("info"), save_logs: z.boolean().optional().default(false), log_failed_attempts: z.boolean().optional().default(false), telemetry: z .object({ anonymous_usage: z.boolean().optional().default(true) }) .optional() .prefault({}), notifications: z .object({ product_updates: z.boolean().optional().default(true), new_releases: z.boolean().optional().default(true) }) .optional() .prefault({}) }) .optional() .default({ log_level: "info", save_logs: false, log_failed_attempts: false, telemetry: { anonymous_usage: true }, notifications: { product_updates: true, new_releases: true } }), domains: z .record( z.string(), z.object({ base_domain: z .string() .nonempty("base_domain must not be empty") .transform((url) => url.toLowerCase()), cert_resolver: z.string().optional(), // null falls back to traefik.cert_resolver prefer_wildcard_cert: z.boolean().optional().default(false) }) ) .optional(), server: z .object({ integration_port: portSchema .optional() .default(3003) .transform(stoi) .pipe(portSchema.optional()), external_port: portSchema .optional() .default(3000) .transform(stoi) .pipe(portSchema), internal_port: portSchema .optional() .default(3001) .transform(stoi) .pipe(portSchema), next_port: portSchema .optional() .default(3002) .transform(stoi) .pipe(portSchema), internal_hostname: z .string() .optional() .default("pangolin") .transform((url) => url.toLowerCase()), session_cookie_name: z .string() .optional() .default("p_session_token"), resource_access_token_param: z .string() .optional() .default("p_token"), resource_access_token_headers: z .object({ id: z.string().optional().default("P-Access-Token-Id"), token: z.string().optional().default("P-Access-Token") }) .optional() .prefault({}), resource_session_request_param: z .string() .optional() .default("resource_session_request_param"), dashboard_session_length_hours: z .number() .positive() .gt(0) .optional() .default(720), resource_session_length_hours: z .number() .positive() .gt(0) .optional() .default(720), cors: z .object({ origins: z.array(z.string()).optional(), methods: z.array(z.string()).optional(), allowed_headers: z.array(z.string()).optional(), credentials: z.boolean().optional() }) .optional(), trust_proxy: z.int().gte(0).optional().default(1), secret: z.string().pipe(z.string().min(8)).optional(), maxmind_db_path: z.string().optional(), maxmind_asn_path: z.string().optional() }) .optional() .default({ integration_port: 3003, external_port: 3000, internal_port: 3001, next_port: 3002, internal_hostname: "pangolin", session_cookie_name: "p_session_token", resource_access_token_param: "p_token", resource_access_token_headers: { id: "P-Access-Token-Id", token: "P-Access-Token" }, resource_session_request_param: "resource_session_request_param", dashboard_session_length_hours: 720, resource_session_length_hours: 720, trust_proxy: 1 }), postgres: z .object({ connection_string: z.string().optional(), replicas: z .array( z.object({ connection_string: z.string() }) ) .optional(), pool: z .object({ max_connections: z .number() .positive() .optional() .default(20), max_replica_connections: z .number() .positive() .optional() .default(10), idle_timeout_ms: z .number() .positive() .optional() .default(30000), connection_timeout_ms: z .number() .positive() .optional() .default(5000) }) .optional() .prefault({}) }) .optional(), postgres_logs: z .object({ connection_string: z .string() .optional() .transform(getEnvOrYaml("POSTGRES_LOGS_CONNECTION_STRING")), replicas: z .array( z.object({ connection_string: z.string() }) ) .optional(), pool: z .object({ max_connections: z .number() .positive() .optional() .default(20), max_replica_connections: z .number() .positive() .optional() .default(10), idle_timeout_ms: z .number() .positive() .optional() .default(30000), connection_timeout_ms: z .number() .positive() .optional() .default(5000) }) .optional() .prefault({}) }) .optional(), traefik: z .object({ http_entrypoint: z.string().optional().default("web"), https_entrypoint: z.string().optional().default("websecure"), additional_middlewares: z.array(z.string()).optional(), cert_resolver: z.string().optional().default("letsencrypt"), prefer_wildcard_cert: z.boolean().optional().default(false), certificates_path: z.string().default("/var/certificates"), monitor_interval: z.number().default(5000), dynamic_cert_config_path: z .string() .optional() .default("/var/dynamic/cert_config.yml"), dynamic_router_config_path: z .string() .optional() .default("/var/dynamic/router_config.yml"), static_domains: z.array(z.string()).optional().default([]), site_types: z .array(z.string()) .optional() .default(["newt", "wireguard", "local"]), allow_raw_resources: z.boolean().optional().default(true), file_mode: z.boolean().optional().default(false), pp_transport_prefix: z .string() .optional() .default("pp-transport-v") }) .optional() .prefault({}), gerbil: z .object({ exit_node_name: z.string().optional(), start_port: portSchema .optional() .default(51820) .transform(stoi) .pipe(portSchema), clients_start_port: portSchema .optional() .default(21820) .transform(stoi) .pipe(portSchema), base_endpoint: z .string() .optional() .pipe(z.string()) .transform((url) => url.toLowerCase()), use_subdomain: z.boolean().optional().default(false), subnet_group: z.string().optional().default("100.89.137.0/20"), block_size: z.number().positive().gt(0).optional().default(24), site_block_size: z .number() .positive() .gt(0) .optional() .default(30) }) .optional() .prefault({}), orgs: z .object({ block_size: z.number().positive().gt(0).optional().default(24), subnet_group: z.string().optional().default("100.90.128.0/20"), utility_subnet_group: z .string() .optional() .default("100.96.128.0/20") //just hardcode this for now as well }) .optional() .default({ block_size: 24, subnet_group: "100.90.128.0/24", utility_subnet_group: "100.96.128.0/24" }), rate_limits: z .object({ global: z .object({ window_minutes: z .number() .positive() .gt(0) .optional() .default(1), max_requests: z .number() .positive() .gt(0) .optional() .default(500) }) .optional() .prefault({}), auth: z .object({ window_minutes: z .number() .positive() .gt(0) .optional() .default(1), max_requests: z .number() .positive() .gt(0) .optional() .default(500) }) .optional() .prefault({}) }) .optional() .prefault({}), email: z .object({ smtp_host: z.string().optional(), smtp_port: portSchema.optional(), smtp_user: z .string() .optional() .transform(getEnvOrYaml("EMAIL_SMTP_USER")), smtp_pass: z .string() .optional() .transform(getEnvOrYaml("EMAIL_SMTP_PASS")), smtp_secure: z.boolean().optional(), smtp_tls_reject_unauthorized: z.boolean().optional(), no_reply: z.email().optional() }) .optional(), flags: z .object({ require_email_verification: z.boolean().optional(), disable_signup_without_invite: z.boolean().optional(), disable_user_create_org: z.boolean().optional(), allow_raw_resources: z.boolean().optional(), enable_integration_api: z.boolean().optional(), disable_local_sites: z.boolean().optional(), disable_basic_wireguard_sites: z.boolean().optional(), disable_config_managed_domains: z.boolean().optional(), disable_product_help_banners: z.boolean().optional(), disable_enterprise_features: z.boolean().optional() }) .optional(), dns: z .object({ nameservers: z .array(z.string().optional().optional()) .optional() .default([ "ns1.pangolin.net", "ns2.pangolin.net", "ns3.pangolin.net" ]), cname_extension: z .string() .optional() .default("cname.pangolin.net") }) .optional() .prefault({}) }) .refine( (data) => { const keys = Object.keys(data.domains || {}); if (data.flags?.disable_config_managed_domains) { return true; } if (keys.length === 0) { return false; } return true; }, { error: "At least one domain must be defined" } ) .refine( (data) => { // If hybrid is not defined, server secret must be defined. If its not defined already then pull it from env if (data.server?.secret === undefined) { data.server.secret = process.env.SERVER_SECRET; } return ( data.server?.secret !== undefined && data.server.secret.length > 0 ); }, { error: "Server secret must be defined" } ) .refine( (data) => { // If hybrid is not defined, dashboard_url must be defined return ( data.app.dashboard_url !== undefined && data.app.dashboard_url.length > 0 ); }, { error: "Dashboard URL must be defined" } ); export function readConfigFile() { const loadConfig = (configPath: string) => { try { const yamlContent = fs.readFileSync(configPath, "utf8"); const config = yaml.load(yamlContent); return config; } catch (error) { if (error instanceof Error) { throw new Error( `Error loading configuration file: ${error.message}` ); } throw error; } }; let environment: any; if (fs.existsSync(configFilePath1)) { environment = loadConfig(configFilePath1); } else if (fs.existsSync(configFilePath2)) { environment = loadConfig(configFilePath2); } if (!environment) { throw new Error( "No configuration file found. Please create one. https://docs.pangolin.net/self-host/advanced/config-file" ); } return environment; } ================================================ FILE: server/lib/rebuildClientAssociations.ts ================================================ import { Client, clients, clientSiteResources, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, exitNodes, newts, olms, roleSiteResources, Site, SiteResource, siteResources, sites, Transaction, userOrgs, userSiteResources } from "@server/db"; import { and, eq, inArray, ne } from "drizzle-orm"; import { addPeer as newtAddPeer, deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; import { initPeerAddHandshake, deletePeer as olmDeletePeer } from "@server/routers/olm/peers"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import logger from "@server/logger"; import { generateAliasConfig, generateRemoteSubnets, generateSubnetProxyTargets, parseEndpoint, formatEndpoint } from "@server/lib/ip"; import { addPeerData, addTargets as addSubnetProxyTargets, removePeerData, removeTargets as removeSubnetProxyTargets } from "@server/routers/client/targets"; export async function getClientSiteResourceAccess( siteResource: SiteResource, trx: Transaction | typeof db = db ) { // get the site const [site] = await trx .select() .from(sites) .where(eq(sites.siteId, siteResource.siteId)) .limit(1); if (!site) { throw new Error(`Site with ID ${siteResource.siteId} not found`); } const roleIds = await trx .select() .from(roleSiteResources) .where( eq(roleSiteResources.siteResourceId, siteResource.siteResourceId) ) .then((rows) => rows.map((row) => row.roleId)); const directUserIds = await trx .select() .from(userSiteResources) .where( eq(userSiteResources.siteResourceId, siteResource.siteResourceId) ) .then((rows) => rows.map((row) => row.userId)); // get all of the users in these roles const userIdsFromRoles = await trx .select({ userId: userOrgs.userId }) .from(userOrgs) .where(inArray(userOrgs.roleId, roleIds)) .then((rows) => rows.map((row) => row.userId)); const newAllUserIds = Array.from( new Set([...directUserIds, ...userIdsFromRoles]) ); const newAllClients = await trx .select({ clientId: clients.clientId, pubKey: clients.pubKey, subnet: clients.subnet }) .from(clients) .where( and( inArray(clients.userId, newAllUserIds), eq(clients.orgId, siteResource.orgId) // filter by org to prevent cross-org associations ) ); const allClientSiteResources = await trx // this is for if a client is directly associated with a resource instead of implicitly via a user .select() .from(clientSiteResources) .where( eq(clientSiteResources.siteResourceId, siteResource.siteResourceId) ); const directClientIds = allClientSiteResources.map((row) => row.clientId); // Get full client details for directly associated clients const directClients = directClientIds.length > 0 ? await trx .select({ clientId: clients.clientId, pubKey: clients.pubKey, subnet: clients.subnet }) .from(clients) .where( and( inArray(clients.clientId, directClientIds), eq(clients.orgId, siteResource.orgId) // filter by org to prevent cross-org associations ) ) : []; // Merge user-based clients with directly associated clients const allClientsMap = new Map( [...newAllClients, ...directClients].map((c) => [c.clientId, c]) ); const mergedAllClients = Array.from(allClientsMap.values()); const mergedAllClientIds = mergedAllClients.map((c) => c.clientId); return { site, mergedAllClients, mergedAllClientIds }; } export async function rebuildClientAssociationsFromSiteResource( siteResource: SiteResource, trx: Transaction | typeof db = db ): Promise<{ mergedAllClients: { clientId: number; pubKey: string | null; subnet: string | null; }[]; }> { const siteId = siteResource.siteId; const { site, mergedAllClients, mergedAllClientIds } = await getClientSiteResourceAccess(siteResource, trx); /////////// process the client-siteResource associations /////////// // get all of the clients associated with other resources on this site const allUpdatedClientsFromOtherResourcesOnThisSite = await trx .select({ clientId: clientSiteResourcesAssociationsCache.clientId }) .from(clientSiteResourcesAssociationsCache) .innerJoin( siteResources, eq( clientSiteResourcesAssociationsCache.siteResourceId, siteResources.siteResourceId ) ) .where( and( eq(siteResources.siteId, siteId), ne(siteResources.siteResourceId, siteResource.siteResourceId) ) ); const allClientIdsFromOtherResourcesOnThisSite = Array.from( new Set( allUpdatedClientsFromOtherResourcesOnThisSite.map( (row) => row.clientId ) ) ); const existingClientSiteResources = await trx .select({ clientId: clientSiteResourcesAssociationsCache.clientId }) .from(clientSiteResourcesAssociationsCache) .where( eq( clientSiteResourcesAssociationsCache.siteResourceId, siteResource.siteResourceId ) ); const existingClientSiteResourceIds = existingClientSiteResources.map( (row) => row.clientId ); // Get full client details for existing resource clients (needed for sending delete messages) const existingResourceClients = existingClientSiteResourceIds.length > 0 ? await trx .select({ clientId: clients.clientId, pubKey: clients.pubKey, subnet: clients.subnet }) .from(clients) .where( inArray(clients.clientId, existingClientSiteResourceIds) ) : []; const clientSiteResourcesToAdd = mergedAllClientIds.filter( (clientId) => !existingClientSiteResourceIds.includes(clientId) ); const clientSiteResourcesToInsert = clientSiteResourcesToAdd.map( (clientId) => ({ clientId, siteResourceId: siteResource.siteResourceId }) ); if (clientSiteResourcesToInsert.length > 0) { await trx .insert(clientSiteResourcesAssociationsCache) .values(clientSiteResourcesToInsert) .returning(); } const clientSiteResourcesToRemove = existingClientSiteResourceIds.filter( (clientId) => !mergedAllClientIds.includes(clientId) ); if (clientSiteResourcesToRemove.length > 0) { await trx .delete(clientSiteResourcesAssociationsCache) .where( and( eq( clientSiteResourcesAssociationsCache.siteResourceId, siteResource.siteResourceId ), inArray( clientSiteResourcesAssociationsCache.clientId, clientSiteResourcesToRemove ) ) ); } /////////// process the client-site associations /////////// const existingClientSites = await trx .select({ clientId: clientSitesAssociationsCache.clientId }) .from(clientSitesAssociationsCache) .where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId)); const existingClientSiteIds = existingClientSites.map( (row) => row.clientId ); // Get full client details for existing clients (needed for sending delete messages) const existingClients = await trx .select({ clientId: clients.clientId, pubKey: clients.pubKey, subnet: clients.subnet }) .from(clients) .where(inArray(clients.clientId, existingClientSiteIds)); const clientSitesToAdd = mergedAllClientIds.filter( (clientId) => !existingClientSiteIds.includes(clientId) && !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource ); const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ clientId, siteId })); if (clientSitesToInsert.length > 0) { await trx .insert(clientSitesAssociationsCache) .values(clientSitesToInsert) .returning(); } // Now remove any client-site associations that should no longer exist const clientSitesToRemove = existingClientSiteIds.filter( (clientId) => !mergedAllClientIds.includes(clientId) && !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource ); if (clientSitesToRemove.length > 0) { await trx .delete(clientSitesAssociationsCache) .where( and( eq(clientSitesAssociationsCache.siteId, siteId), inArray( clientSitesAssociationsCache.clientId, clientSitesToRemove ) ) ); } /////////// send the messages /////////// // Now handle the messages to add/remove peers on both the newt and olm sides await handleMessagesForSiteClients( site, siteId, mergedAllClients, existingClients, clientSitesToAdd, clientSitesToRemove, trx ); // Handle subnet proxy target updates for the resource associations await handleSubnetProxyTargetUpdates( siteResource, mergedAllClients, existingResourceClients, clientSiteResourcesToAdd, clientSiteResourcesToRemove, trx ); return { mergedAllClients }; } async function handleMessagesForSiteClients( site: Site, siteId: number, allClients: { clientId: number; pubKey: string | null; subnet: string | null; }[], existingClients: { clientId: number; pubKey: string | null; subnet: string | null; }[], clientSitesToAdd: number[], clientSitesToRemove: number[], trx: Transaction | typeof db = db ): Promise { if (!site.exitNodeId) { logger.warn( `Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline` ); return; } // get the exit node for the site const [exitNode] = await trx .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, site.exitNodeId)) .limit(1); if (!exitNode) { logger.warn( `Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline` ); return; } if (!site.publicKey) { logger.warn( `Site publicKey not set for site ${site.siteId} so cannot add peers to clients` ); return; } const [newt] = await trx .select({ newtId: newts.newtId }) .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); if (!newt) { logger.warn( `Newt not found for site ${siteId} so cannot add peers to clients` ); return; } const newtJobs: Promise[] = []; const olmJobs: Promise[] = []; const exitNodeJobs: Promise[] = []; // Combine all clients that need processing (those being added or removed) const clientsToProcess = new Map< number, { clientId: number; pubKey: string | null; subnet: string | null; } >(); // Add clients that are being added (from newAllClients) for (const client of allClients) { if (clientSitesToAdd.includes(client.clientId)) { clientsToProcess.set(client.clientId, client); } } // Add clients that are being removed (from existingClients) for (const client of existingClients) { if (clientSitesToRemove.includes(client.clientId)) { clientsToProcess.set(client.clientId, client); } } for (const client of clientsToProcess.values()) { // UPDATE THE NEWT if (!client.subnet || !client.pubKey) { logger.debug("Client subnet, pubKey or endpoint is not set"); continue; } // is this an add or a delete? const isAdd = clientSitesToAdd.includes(client.clientId); const isDelete = clientSitesToRemove.includes(client.clientId); if (!isAdd && !isDelete) { // nothing to do for this client continue; } const [olm] = await trx .select({ olmId: olms.olmId }) .from(olms) .where(eq(olms.clientId, client.clientId)) .limit(1); if (!olm) { logger.warn( `Olm not found for client ${client.clientId} so cannot add/delete peers` ); continue; } if (isDelete) { newtJobs.push(newtDeletePeer(siteId, client.pubKey, newt.newtId)); olmJobs.push( olmDeletePeer( client.clientId, siteId, site.publicKey, olm.olmId ) ); } if (isAdd) { // TODO: if we are in jit mode here should we really be sending this? await initPeerAddHandshake( // this will kick off the add peer process for the client client.clientId, { siteId, exitNode: { publicKey: exitNode.publicKey, endpoint: exitNode.endpoint } }, olm.olmId ); } exitNodeJobs.push(updateClientSiteDestinations(client, trx)); } await Promise.all(exitNodeJobs); await Promise.all(newtJobs); // do the servers first to make sure they are ready? await Promise.all(olmJobs); } interface PeerDestination { destinationIP: string; destinationPort: number; } // this updates the relay destinations for a client to point to all of the new sites export async function updateClientSiteDestinations( client: { clientId: number; pubKey: string | null; subnet: string | null; }, trx: Transaction | typeof db = db ): Promise { let exitNodeDestinations: { reachableAt: string; exitNodeId: number; type: string; name: string; sourceIp: string; sourcePort: number; destinations: PeerDestination[]; }[] = []; const sitesData = await trx .select() .from(sites) .innerJoin( clientSitesAssociationsCache, eq(sites.siteId, clientSitesAssociationsCache.siteId) ) .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); for (const site of sitesData) { if (!site.sites.subnet) { logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`); continue; } if (!site.clientSitesAssociationsCache.endpoint) { // if this is a new association the endpoint is not set yet continue; } // Parse the endpoint properly for both IPv4 and IPv6 const parsedEndpoint = parseEndpoint( site.clientSitesAssociationsCache.endpoint ); if (!parsedEndpoint) { logger.warn( `Failed to parse endpoint ${site.clientSitesAssociationsCache.endpoint}, skipping` ); continue; } // find the destinations in the array let destinations = exitNodeDestinations.find( (d) => d.reachableAt === site.exitNodes?.reachableAt ); if (!destinations) { destinations = { reachableAt: site.exitNodes?.reachableAt || "", exitNodeId: site.exitNodes?.exitNodeId || 0, type: site.exitNodes?.type || "", name: site.exitNodes?.name || "", sourceIp: parsedEndpoint.ip, sourcePort: parsedEndpoint.port, destinations: [ { destinationIP: site.sites.subnet.split("/")[0], destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated } ] }; } else { // add to the existing destinations destinations.destinations.push({ destinationIP: site.sites.subnet.split("/")[0], destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }); } // update it in the array exitNodeDestinations = exitNodeDestinations.filter( (d) => d.reachableAt !== site.exitNodes?.reachableAt ); exitNodeDestinations.push(destinations); } for (const destination of exitNodeDestinations) { logger.info( `Updating destinations for exit node at ${destination.reachableAt}` ); const payload = { sourceIp: destination.sourceIp, sourcePort: destination.sourcePort, destinations: destination.destinations }; logger.info( `Payload for update-destinations: ${JSON.stringify(payload, null, 2)}` ); // Create an ExitNode-like object for sendToExitNode const exitNodeForComm = { exitNodeId: destination.exitNodeId, type: destination.type, reachableAt: destination.reachableAt, name: destination.name } as any; // Using 'as any' since we know sendToExitNode will handle this correctly await sendToExitNode(exitNodeForComm, { remoteType: "remoteExitNode/update-destinations", localPath: "/update-destinations", method: "POST", data: payload }); } } async function handleSubnetProxyTargetUpdates( siteResource: SiteResource, allClients: { clientId: number; pubKey: string | null; subnet: string | null; }[], existingClients: { clientId: number; pubKey: string | null; subnet: string | null; }[], clientSiteResourcesToAdd: number[], clientSiteResourcesToRemove: number[], trx: Transaction | typeof db = db ): Promise { // Get the newt for this site const [newt] = await trx .select() .from(newts) .where(eq(newts.siteId, siteResource.siteId)) .limit(1); if (!newt) { logger.warn( `Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates` ); return; } const proxyJobs = []; const olmJobs = []; // Generate targets for added associations if (clientSiteResourcesToAdd.length > 0) { const addedClients = allClients.filter((client) => clientSiteResourcesToAdd.includes(client.clientId) ); if (addedClients.length > 0) { const targetsToAdd = generateSubnetProxyTargets( siteResource, addedClients ); if (targetsToAdd.length > 0) { logger.info( `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` ); proxyJobs.push( addSubnetProxyTargets( newt.newtId, targetsToAdd, newt.version ) ); } for (const client of addedClients) { olmJobs.push( addPeerData( client.clientId, siteResource.siteId, generateRemoteSubnets([siteResource]), generateAliasConfig([siteResource]) ) ); } } } // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here // Generate targets for removed associations if (clientSiteResourcesToRemove.length > 0) { const removedClients = existingClients.filter((client) => clientSiteResourcesToRemove.includes(client.clientId) ); if (removedClients.length > 0) { const targetsToRemove = generateSubnetProxyTargets( siteResource, removedClients ); if (targetsToRemove.length > 0) { logger.info( `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` ); proxyJobs.push( removeSubnetProxyTargets( newt.newtId, targetsToRemove, newt.version ) ); } for (const client of removedClients) { // Check if this client still has access to another resource on this site with the same destination const destinationStillInUse = await trx .select() .from(siteResources) .innerJoin( clientSiteResourcesAssociationsCache, eq( clientSiteResourcesAssociationsCache.siteResourceId, siteResources.siteResourceId ) ) .where( and( eq( clientSiteResourcesAssociationsCache.clientId, client.clientId ), eq(siteResources.siteId, siteResource.siteId), eq( siteResources.destination, siteResource.destination ), ne( siteResources.siteResourceId, siteResource.siteResourceId ) ) ); // Only remove remote subnet if no other resource uses the same destination const remoteSubnetsToRemove = destinationStillInUse.length > 0 ? [] : generateRemoteSubnets([siteResource]); olmJobs.push( removePeerData( client.clientId, siteResource.siteId, remoteSubnetsToRemove, generateAliasConfig([siteResource]) ) ); } } } await Promise.all(proxyJobs); } export async function rebuildClientAssociationsFromClient( client: Client, trx: Transaction | typeof db = db ): Promise { let newSiteResourceIds: number[] = []; // 1. Direct client associations const directSiteResources = await trx .select({ siteResourceId: clientSiteResources.siteResourceId }) .from(clientSiteResources) .innerJoin( siteResources, eq(siteResources.siteResourceId, clientSiteResources.siteResourceId) ) .where( and( eq(clientSiteResources.clientId, client.clientId), eq(siteResources.orgId, client.orgId) // filter by org to prevent cross-org associations ) ); newSiteResourceIds.push( ...directSiteResources.map((r) => r.siteResourceId) ); // 2. User-based and role-based access (if client has a userId) if (client.userId) { // Direct user associations const userSiteResourceIds = await trx .select({ siteResourceId: userSiteResources.siteResourceId }) .from(userSiteResources) .innerJoin( siteResources, eq( siteResources.siteResourceId, userSiteResources.siteResourceId ) ) .where( and( eq(userSiteResources.userId, client.userId), eq(siteResources.orgId, client.orgId) ) ); // this needs to be locked onto this org or else cross-org access could happen newSiteResourceIds.push( ...userSiteResourceIds.map((r) => r.siteResourceId) ); // Role-based access const roleIds = await trx .select({ roleId: userOrgs.roleId }) .from(userOrgs) .where( and( eq(userOrgs.userId, client.userId), eq(userOrgs.orgId, client.orgId) ) ) // this needs to be locked onto this org or else cross-org access could happen .then((rows) => rows.map((row) => row.roleId)); if (roleIds.length > 0) { const roleSiteResourceIds = await trx .select({ siteResourceId: roleSiteResources.siteResourceId }) .from(roleSiteResources) .innerJoin( siteResources, eq( siteResources.siteResourceId, roleSiteResources.siteResourceId ) ) .where( and( inArray(roleSiteResources.roleId, roleIds), eq(siteResources.orgId, client.orgId) // filter by org to prevent cross-org associations ) ); newSiteResourceIds.push( ...roleSiteResourceIds.map((r) => r.siteResourceId) ); } } // Remove duplicates newSiteResourceIds = Array.from(new Set(newSiteResourceIds)); // Get full siteResource details const newSiteResources = newSiteResourceIds.length > 0 ? await trx .select() .from(siteResources) .where( inArray(siteResources.siteResourceId, newSiteResourceIds) ) : []; // Group by siteId for site-level associations const newSiteIds = Array.from( new Set(newSiteResources.map((sr) => sr.siteId)) ); /////////// Process client-siteResource associations /////////// // Get existing resource associations const existingResourceAssociations = await trx .select({ siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId }) .from(clientSiteResourcesAssociationsCache) .where( eq(clientSiteResourcesAssociationsCache.clientId, client.clientId) ); const existingSiteResourceIds = existingResourceAssociations.map( (r) => r.siteResourceId ); const resourcesToAdd = newSiteResourceIds.filter( (id) => !existingSiteResourceIds.includes(id) ); const resourcesToRemove = existingSiteResourceIds.filter( (id) => !newSiteResourceIds.includes(id) ); // Insert new associations if (resourcesToAdd.length > 0) { await trx.insert(clientSiteResourcesAssociationsCache).values( resourcesToAdd.map((siteResourceId) => ({ clientId: client.clientId, siteResourceId })) ); } // Remove old associations if (resourcesToRemove.length > 0) { await trx .delete(clientSiteResourcesAssociationsCache) .where( and( eq( clientSiteResourcesAssociationsCache.clientId, client.clientId ), inArray( clientSiteResourcesAssociationsCache.siteResourceId, resourcesToRemove ) ) ); } /////////// Process client-site associations /////////// // Get existing site associations const existingSiteAssociations = await trx .select({ siteId: clientSitesAssociationsCache.siteId }) .from(clientSitesAssociationsCache) .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); const existingSiteIds = existingSiteAssociations.map((s) => s.siteId); const sitesToAdd = newSiteIds.filter((id) => !existingSiteIds.includes(id)); const sitesToRemove = existingSiteIds.filter( (id) => !newSiteIds.includes(id) ); // Insert new site associations if (sitesToAdd.length > 0) { await trx.insert(clientSitesAssociationsCache).values( sitesToAdd.map((siteId) => ({ clientId: client.clientId, siteId })) ); } // Remove old site associations if (sitesToRemove.length > 0) { await trx .delete(clientSitesAssociationsCache) .where( and( eq(clientSitesAssociationsCache.clientId, client.clientId), inArray(clientSitesAssociationsCache.siteId, sitesToRemove) ) ); } /////////// Send messages /////////// // Handle messages for sites being added await handleMessagesForClientSites(client, sitesToAdd, sitesToRemove, trx); // Handle subnet proxy target updates for resources await handleMessagesForClientResources( client, newSiteResources, resourcesToAdd, resourcesToRemove, trx ); } async function handleMessagesForClientSites( client: { clientId: number; pubKey: string | null; subnet: string | null; userId: string | null; orgId: string; }, sitesToAdd: number[], sitesToRemove: number[], trx: Transaction | typeof db = db ): Promise { // Get the olm for this client const [olm] = await trx .select({ olmId: olms.olmId }) .from(olms) .where(eq(olms.clientId, client.clientId)) .limit(1); if (!olm) { logger.warn( `Olm not found for client ${client.clientId}, skipping peer updates` ); return; } const olmId = olm.olmId; if (!client.subnet || !client.pubKey) { logger.warn( `Client ${client.clientId} missing subnet or pubKey, skipping peer updates` ); return; } const allSiteIds = [...sitesToAdd, ...sitesToRemove]; if (allSiteIds.length === 0) { return; } // Get site details for all affected sites const sitesData = await trx .select() .from(sites) .leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) .leftJoin(newts, eq(sites.siteId, newts.siteId)) .where(inArray(sites.siteId, allSiteIds)); const newtJobs: Promise[] = []; const olmJobs: Promise[] = []; const exitNodeJobs: Promise[] = []; for (const siteData of sitesData) { const site = siteData.sites; const exitNode = siteData.exitNodes; const newt = siteData.newt; if (!site.publicKey) { logger.warn( `Site ${site.siteId} missing publicKey, skipping peer updates` ); continue; } if (!newt) { logger.warn( `Newt not found for site ${site.siteId}, skipping peer updates` ); continue; } const isAdd = sitesToAdd.includes(site.siteId); const isRemove = sitesToRemove.includes(site.siteId); if (isRemove) { // Remove peer from newt newtJobs.push( newtDeletePeer(site.siteId, client.pubKey, newt.newtId) ); try { // Remove peer from olm olmJobs.push( olmDeletePeer( client.clientId, site.siteId, site.publicKey, olmId ) ); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( error instanceof Error && error.message.includes("not found") ) { logger.debug( `Olm data not found for client ${client.clientId}, skipping removal` ); } else { throw error; } } } if (isAdd) { if (!exitNode) { logger.warn( `Exit node not found for site ${site.siteId}, skipping peer add` ); continue; } // TODO: if we are in jit mode here should we really be sending this? await initPeerAddHandshake( // this will kick off the add peer process for the client client.clientId, { siteId: site.siteId, exitNode: { publicKey: exitNode.publicKey, endpoint: exitNode.endpoint } }, olmId ); } // Update exit node destinations exitNodeJobs.push( updateClientSiteDestinations( { clientId: client.clientId, pubKey: client.pubKey, subnet: client.subnet }, trx ) ); } await Promise.all(exitNodeJobs); await Promise.all(newtJobs); await Promise.all(olmJobs); } async function handleMessagesForClientResources( client: { clientId: number; pubKey: string | null; subnet: string | null; userId: string | null; orgId: string; }, allNewResources: SiteResource[], resourcesToAdd: number[], resourcesToRemove: number[], trx: Transaction | typeof db = db ): Promise { const proxyJobs: Promise[] = []; const olmJobs: Promise[] = []; // Handle additions if (resourcesToAdd.length > 0) { const addedResources = allNewResources.filter((r) => resourcesToAdd.includes(r.siteResourceId) ); // Group by site for proxy updates const addedBySite = new Map(); for (const resource of addedResources) { if (!addedBySite.has(resource.siteId)) { addedBySite.set(resource.siteId, []); } addedBySite.get(resource.siteId)!.push(resource); } // Add subnet proxy targets for each site for (const [siteId, resources] of addedBySite.entries()) { const [newt] = await trx .select({ newtId: newts.newtId, version: newts.version }) .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); if (!newt) { logger.warn( `Newt not found for site ${siteId}, skipping proxy updates` ); continue; } for (const resource of resources) { const targets = generateSubnetProxyTargets(resource, [ { clientId: client.clientId, pubKey: client.pubKey, subnet: client.subnet } ]); if (targets.length > 0) { proxyJobs.push( addSubnetProxyTargets( newt.newtId, targets, newt.version ) ); } try { // Add peer data to olm olmJobs.push( addPeerData( client.clientId, resource.siteId, generateRemoteSubnets([resource]), generateAliasConfig([resource]) ) ); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( error instanceof Error && error.message.includes("not found") ) { logger.debug( `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` ); } else { throw error; } } } } } // Handle removals if (resourcesToRemove.length > 0) { const removedResources = await trx .select() .from(siteResources) .where(inArray(siteResources.siteResourceId, resourcesToRemove)); // Group by site for proxy updates const removedBySite = new Map(); for (const resource of removedResources) { if (!removedBySite.has(resource.siteId)) { removedBySite.set(resource.siteId, []); } removedBySite.get(resource.siteId)!.push(resource); } // Remove subnet proxy targets for each site for (const [siteId, resources] of removedBySite.entries()) { const [newt] = await trx .select({ newtId: newts.newtId, version: newts.version }) .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); if (!newt) { logger.warn( `Newt not found for site ${siteId}, skipping proxy updates` ); continue; } for (const resource of resources) { const targets = generateSubnetProxyTargets(resource, [ { clientId: client.clientId, pubKey: client.pubKey, subnet: client.subnet } ]); if (targets.length > 0) { proxyJobs.push( removeSubnetProxyTargets( newt.newtId, targets, newt.version ) ); } try { // Check if this client still has access to another resource on this site with the same destination const destinationStillInUse = await trx .select() .from(siteResources) .innerJoin( clientSiteResourcesAssociationsCache, eq( clientSiteResourcesAssociationsCache.siteResourceId, siteResources.siteResourceId ) ) .where( and( eq( clientSiteResourcesAssociationsCache.clientId, client.clientId ), eq(siteResources.siteId, resource.siteId), eq( siteResources.destination, resource.destination ), ne( siteResources.siteResourceId, resource.siteResourceId ) ) ); // Only remove remote subnet if no other resource uses the same destination const remoteSubnetsToRemove = destinationStillInUse.length > 0 ? [] : generateRemoteSubnets([resource]); // Remove peer data from olm olmJobs.push( removePeerData( client.clientId, resource.siteId, remoteSubnetsToRemove, generateAliasConfig([resource]) ) ); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( error instanceof Error && error.message.includes("not found") ) { logger.debug( `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal` ); } else { throw error; } } } } } await Promise.all([...proxyJobs, ...olmJobs]); } ================================================ FILE: server/lib/response.ts ================================================ import { ResponseT } from "@server/types/Response"; import { Response } from "express"; export const response = ( res: Response, { data, success, error, message, status }: ResponseT ) => { return res.status(status).send({ data, success, error, message, status }); }; export default response; ================================================ FILE: server/lib/s3.ts ================================================ import { S3Client } from "@aws-sdk/client-s3"; export const s3Client = new S3Client({ region: process.env.S3_REGION || "us-east-1" }); ================================================ FILE: server/lib/schemas.ts ================================================ import { z } from "zod"; export const subdomainSchema = z .string() .regex( /^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/, "Invalid subdomain format" ) .min(1, "Subdomain must be at least 1 character long") .transform((val) => val.toLowerCase()); export const tlsNameSchema = z .string() .regex( /^(?!:\/\/)([a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$|^$/, "Invalid subdomain format" ) .transform((val) => val.toLowerCase()); export const privateNamespaceSubdomainSchema = z .string() .regex( /^[a-zA-Z0-9-]+$/, "Namespace subdomain can only contain letters, numbers, and hyphens" ) .min(1, "Namespace subdomain must be at least 1 character long") .max(32, "Namespace subdomain must be at most 32 characters long") .transform((val) => val.toLowerCase()); ================================================ FILE: server/lib/serverIpService.ts ================================================ import logger from "@server/logger"; import axios from "axios"; let serverIp: string | null = null; const services = [ "https://checkip.amazonaws.com", "https://ifconfig.io/ip", "https://api.ipify.org" ]; export async function fetchServerIp() { for (const url of services) { try { const response = await axios.get(url, { timeout: 5000 }); serverIp = response.data.trim(); logger.debug("Detected public IP: " + serverIp); return; } catch (err: any) { console.warn( `Failed to fetch server IP from ${url}: ${err.message || err.code}` ); } } console.error("All attempts to fetch server IP failed."); } export function getServerIp() { return serverIp; } ================================================ FILE: server/lib/sshCA.ts ================================================ 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; /** 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): 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 { 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) }; } ================================================ FILE: server/lib/stoi.ts ================================================ export default function stoi(val: any) { if (typeof val === "string") { return parseInt(val); } else { return val; } } ================================================ FILE: server/lib/telemetry.ts ================================================ import { PostHog } from "posthog-node"; import config from "./config"; import { getHostMeta } from "./hostMeta"; import logger from "@server/logger"; import { apiKeys, db, roles, siteResources } from "@server/db"; import { sites, users, orgs, resources, clients, idp } from "@server/db"; import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm"; import { APP_VERSION } from "./consts"; import crypto from "crypto"; import { UserType } from "@server/types/UserTypes"; import { build } from "@server/build"; import license from "@server/license/license"; class TelemetryClient { private client: PostHog | null = null; private enabled: boolean; private intervalId: NodeJS.Timeout | null = null; constructor() { const enabled = config.getRawConfig().app.telemetry.anonymous_usage; this.enabled = enabled; const dev = process.env.ENVIRONMENT !== "prod"; if (dev) { return; } if (build === "saas") { return; } if (this.enabled) { this.client = new PostHog( "phc_QYuATSSZt6onzssWcYJbXLzQwnunIpdGGDTYhzK3VjX", { host: "https://pangolin.net/relay-O7yI" } ); process.on("exit", () => { this.client?.shutdown(); }); this.sendStartupEvents() .catch((err) => { logger.error("Failed to send startup telemetry:", err); }) .then(() => { logger.debug("Successfully sent startup telemetry data"); }); this.startAnalyticsInterval(); logger.info( "Pangolin gathers anonymous usage data to help us better understand how the software is used and guide future improvements and feature development. You can find more details, including instructions for opting out of this anonymous data collection, at: https://docs.pangolin.net/telemetry" ); } else if (!this.enabled) { logger.info( "Analytics usage statistics collection is disabled. If you enable this, you can help us make Pangolin better for everyone. Learn more at: https://docs.pangolin.net/telemetry" ); } } private startAnalyticsInterval() { this.intervalId = setInterval( () => { this.collectAndSendAnalytics() .catch((err) => { logger.error("Failed to collect analytics:", err); }) .then(() => { logger.debug("Successfully sent analytics data"); }); }, 48 * 60 * 60 * 1000 ); this.collectAndSendAnalytics().catch((err) => { logger.error("Failed to collect initial analytics:", err); }); } private anon(value: string): string { return crypto .createHash("sha256") .update(value.toLowerCase()) .digest("hex"); } private async getSystemStats() { try { const [sitesCount] = await db .select({ count: count() }) .from(sites); const [usersCount] = await db .select({ count: count() }) .from(users); const [usersInternalCount] = await db .select({ count: count() }) .from(users) .where(eq(users.type, UserType.Internal)); const [usersOidcCount] = await db .select({ count: count() }) .from(users) .where(eq(users.type, UserType.OIDC)); const [orgsCount] = await db.select({ count: count() }).from(orgs); const [resourcesCount] = await db .select({ count: count() }) .from(resources); const [userDevicesCount] = await db .select({ count: count() }) .from(clients) .where(isNotNull(clients.userId)); const [machineClients] = await db .select({ count: count() }) .from(clients) .where(isNull(clients.userId)); const [idpCount] = await db.select({ count: count() }).from(idp); const [onlineSitesCount] = await db .select({ count: count() }) .from(sites) .where(eq(sites.online, true)); const [numApiKeys] = await db .select({ count: count() }) .from(apiKeys); const [customRoles] = await db .select({ count: count() }) .from(roles) .where( and( eq(roles.isAdmin, false), notInArray(roles.name, ["Member"]) ) ); const adminUsers = await db .select({ email: users.email }) .from(users) .where(eq(users.serverAdmin, true)); const resourceDetails = await db .select({ name: resources.name, sso: resources.sso, protocol: resources.protocol, http: resources.http }) .from(resources); const siteDetails = await db .select({ siteName: sites.name, megabytesIn: sites.megabytesIn, megabytesOut: sites.megabytesOut, type: sites.type, online: sites.online }) .from(sites); const supporterKey = config.getSupporterData(); const allPrivateResources = await db.select().from(siteResources); const numPrivResources = allPrivateResources.length; let numPrivResourceAliases = 0; let numPrivResourceHosts = 0; let numPrivResourceCidr = 0; for (const res of allPrivateResources) { if (res.mode === "host") { numPrivResourceHosts += 1; } else if (res.mode === "cidr") { numPrivResourceCidr += 1; } if (res.alias) { numPrivResourceAliases += 1; } } return { numSites: sitesCount.count, numUsers: usersCount.count, numUsersInternal: usersInternalCount.count, numUsersOidc: usersOidcCount.count, numOrganizations: orgsCount.count, numResources: resourcesCount.count, numPrivateResources: numPrivResources, numPrivateResourceAliases: numPrivResourceAliases, numPrivateResourceHosts: numPrivResourceHosts, numUserDevices: userDevicesCount.count, numMachineClients: machineClients.count, numIdentityProviders: idpCount.count, numSitesOnline: onlineSitesCount.count, resources: resourceDetails, adminUsers: adminUsers.map((u) => u.email), sites: siteDetails, appVersion: APP_VERSION, numApiKeys: numApiKeys.count, numCustomRoles: customRoles.count, supporterStatus: { valid: supporterKey?.valid || false, tier: supporterKey?.tier || "None", githubUsername: supporterKey?.githubUsername || null } }; } catch (error) { logger.error("Failed to collect system stats:", error); throw error; } } private async sendStartupEvents() { if (!this.enabled || !this.client) return; const hostMeta = await getHostMeta(); if (!hostMeta) return; const stats = await this.getSystemStats(); if (build === "enterprise") { const licenseStatus = await license.check(); const payload = { distinctId: hostMeta.hostMetaId, event: "enterprise_status", properties: { is_host_licensed: licenseStatus.isHostLicensed, is_license_valid: licenseStatus.isLicenseValid, license_tier: licenseStatus.tier || "unknown" } }; logger.debug("Sending enterprise startup telemetry payload:", { payload }); this.client.capture(payload); } if (build === "oss") { this.client.capture({ distinctId: hostMeta.hostMetaId, event: "supporter_status", properties: { valid: stats.supporterStatus.valid, tier: stats.supporterStatus.tier } }); } this.client.capture({ distinctId: hostMeta.hostMetaId, event: "host_startup", properties: { host_id: hostMeta.hostMetaId, app_version: stats.appVersion, install_timestamp: hostMeta.createdAt } }); } private async collectAndSendAnalytics() { if (!this.enabled || !this.client) return; try { const hostMeta = await getHostMeta(); if (!hostMeta) { logger.warn( "Telemetry: Host meta not found, skipping analytics" ); return; } const stats = await this.getSystemStats(); this.client.capture({ distinctId: hostMeta.hostMetaId, event: "system_analytics", properties: { app_version: stats.appVersion, num_sites: stats.numSites, num_users: stats.numUsers, num_users_internal: stats.numUsersInternal, num_users_oidc: stats.numUsersOidc, num_organizations: stats.numOrganizations, num_resources: stats.numResources, num_private_resources: stats.numPrivateResources, num_private_resource_aliases: stats.numPrivateResourceAliases, num_private_resource_hosts: stats.numPrivateResourceHosts, num_user_devices: stats.numUserDevices, num_machine_clients: stats.numMachineClients, num_identity_providers: stats.numIdentityProviders, num_sites_online: stats.numSitesOnline, num_resources_sso_enabled: stats.resources.filter( (r) => r.sso ).length, num_resources_non_http: stats.resources.filter( (r) => !r.http ).length, num_newt_sites: stats.sites.filter((s) => s.type === "newt") .length, num_local_sites: stats.sites.filter( (s) => s.type === "local" ).length, num_wg_sites: stats.sites.filter( (s) => s.type === "wireguard" ).length, avg_megabytes_in: stats.sites.length > 0 ? Math.round( stats.sites.reduce( (sum, s) => sum + (s.megabytesIn ?? 0), 0 ) / stats.sites.length ) : 0, avg_megabytes_out: stats.sites.length > 0 ? Math.round( stats.sites.reduce( (sum, s) => sum + (s.megabytesOut ?? 0), 0 ) / stats.sites.length ) : 0, num_api_keys: stats.numApiKeys, num_custom_roles: stats.numCustomRoles } }); } catch (error) { logger.error("Failed to send analytics:", error); } } async sendTelemetry(eventName: string, properties: Record) { if (!this.enabled || !this.client) return; const hostMeta = await getHostMeta(); if (!hostMeta) { logger.warn("Telemetry: Host meta not found, skipping telemetry"); return; } this.client.groupIdentify({ groupType: "host_id", groupKey: hostMeta.hostMetaId, properties }); } shutdown() { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } if (this.enabled && this.client) { this.client.shutdown(); } } } let telemetryClient!: TelemetryClient; export function initTelemetryClient() { if (!telemetryClient) { telemetryClient = new TelemetryClient(); } return telemetryClient; } export default telemetryClient; ================================================ FILE: server/lib/totp.ts ================================================ import { alphabet, generateRandomString } from "oslo/crypto"; export async function generateBackupCodes(): Promise { const codes = []; for (let i = 0; i < 10; i++) { const code = generateRandomString(6, alphabet("0-9", "A-Z", "a-z")); codes.push(code); } return codes; } ================================================ FILE: server/lib/traefik/TraefikConfigManager.ts ================================================ import * as fs from "fs"; import * as path from "path"; import config from "@server/lib/config"; import logger from "@server/logger"; import * as yaml from "js-yaml"; import axios from "axios"; import { db, exitNodes } from "@server/db"; import { eq } from "drizzle-orm"; import { getCurrentExitNodeId } from "@server/lib/exitNodes"; import { getTraefikConfig } from "#dynamic/lib/traefik"; import { getValidCertificatesForDomains } from "#dynamic/lib/certificates"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; export class TraefikConfigManager { private intervalId: NodeJS.Timeout | null = null; private isRunning = false; private activeDomains = new Set(); private timeoutId: NodeJS.Timeout | null = null; private lastCertificateFetch: Date | null = null; private lastKnownDomains = new Set(); private lastLocalCertificateState = new Map< string, { exists: boolean; lastModified: number | null; expiresAt: number | null; wildcard: boolean | null; } >(); constructor() {} /** * Start monitoring certificates */ private scheduleNextExecution(): void { const intervalMs = config.getRawConfig().traefik.monitor_interval; const now = Date.now(); const nextExecution = Math.ceil(now / intervalMs) * intervalMs; const delay = nextExecution - now; this.timeoutId = setTimeout(async () => { try { await this.HandleTraefikConfig(); } catch (error) { logger.error("Error during certificate monitoring:", error); } if (this.isRunning) { this.scheduleNextExecution(); // Schedule the next execution } }, delay); } async start(): Promise { if (this.isRunning) { logger.info("Certificate monitor is already running"); return; } this.isRunning = true; logger.info(`Starting certificate monitor for exit node`); // Ensure certificates directory exists await this.ensureDirectoryExists( config.getRawConfig().traefik.certificates_path ); // Initialize local certificate state this.lastLocalCertificateState = await this.scanLocalCertificateState(); logger.info( `Found ${this.lastLocalCertificateState.size} existing certificate directories` ); // Run initial check await this.HandleTraefikConfig(); // Start synchronized scheduling this.scheduleNextExecution(); logger.info( `Certificate monitor started with synchronized ${ config.getRawConfig().traefik.monitor_interval }ms interval` ); } /** * Stop monitoring certificates */ stop(): void { if (!this.isRunning) { logger.info("Certificate monitor is not running"); return; } if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } this.isRunning = false; logger.info("Certificate monitor stopped"); } /** * Scan local certificate directories to build current state */ private async scanLocalCertificateState(): Promise< Map< string, { exists: boolean; lastModified: number | null; expiresAt: number | null; wildcard: boolean; } > > { const state = new Map(); const certsPath = config.getRawConfig().traefik.certificates_path; try { if (!fs.existsSync(certsPath)) { return state; } const certDirs = fs.readdirSync(certsPath, { withFileTypes: true }); for (const dirent of certDirs) { if (!dirent.isDirectory()) continue; const domain = dirent.name; const domainDir = path.join(certsPath, domain); const certPath = path.join(domainDir, "cert.pem"); const keyPath = path.join(domainDir, "key.pem"); const lastUpdatePath = path.join(domainDir, ".last_update"); const wildcardPath = path.join(domainDir, ".wildcard"); const certExists = await this.fileExists(certPath); const keyExists = await this.fileExists(keyPath); const lastUpdateExists = await this.fileExists(lastUpdatePath); const wildcardExists = await this.fileExists(wildcardPath); let lastModified: Date | null = null; let expiresAt: number | null = null; let wildcard = false; const expiresAtPath = path.join(domainDir, ".expires_at"); const expiresAtExists = await this.fileExists(expiresAtPath); if (expiresAtExists) { try { const expiresAtStr = fs .readFileSync(expiresAtPath, "utf8") .trim(); expiresAt = parseInt(expiresAtStr, 10); if (isNaN(expiresAt)) { expiresAt = null; } } catch { expiresAt = null; } } if (lastUpdateExists) { try { const lastUpdateStr = fs .readFileSync(lastUpdatePath, "utf8") .trim(); lastModified = new Date(lastUpdateStr); } catch { // If we can't read the last update, fall back to file stats try { const stats = fs.statSync(certPath); lastModified = stats.mtime; } catch { lastModified = null; } } } // Check if this is a wildcard certificate if (wildcardExists) { try { const wildcardContent = fs .readFileSync(wildcardPath, "utf8") .trim(); wildcard = wildcardContent === "true"; } catch (error) { logger.warn( `Could not read wildcard file for ${domain}:`, error ); } } state.set(domain, { exists: certExists && keyExists, lastModified: lastModified ? Math.floor(lastModified.getTime() / 1000) : null, expiresAt, wildcard }); } } catch (error) { logger.error("Error scanning local certificate state:", error); } return state; } /** * Check if we need to fetch certificates from remote */ private shouldFetchCertificates(currentDomains: Set): boolean { // Always fetch on first run if (!this.lastCertificateFetch) { return true; } const dayInMs = 24 * 60 * 60 * 1000; const timeSinceLastFetch = Date.now() - this.lastCertificateFetch.getTime(); // Fetch if it's been more than 24 hours (daily routine check) if (timeSinceLastFetch > dayInMs) { logger.info("Fetching certificates due to 24-hour renewal check"); return true; } // Filter out domains covered by wildcard certificates const domainsNeedingCerts = new Set(); for (const domain of currentDomains) { if ( !isDomainCoveredByWildcard( domain, this.lastLocalCertificateState ) ) { domainsNeedingCerts.add(domain); } } // Fetch if domains needing certificates have changed const lastDomainsNeedingCerts = new Set(); for (const domain of this.lastKnownDomains) { if ( !isDomainCoveredByWildcard( domain, this.lastLocalCertificateState ) ) { lastDomainsNeedingCerts.add(domain); } } if ( domainsNeedingCerts.size !== lastDomainsNeedingCerts.size || !Array.from(domainsNeedingCerts).every((domain) => lastDomainsNeedingCerts.has(domain) ) ) { logger.info( "Fetching certificates due to domain changes (after wildcard filtering)" ); return true; } // Check if any local certificates are missing (needs immediate fetch) for (const domain of domainsNeedingCerts) { const localState = this.lastLocalCertificateState.get(domain); if (!localState || !localState.exists) { logger.info( `Fetching certificates due to missing local cert for ${domain}` ); return true; } } // For expiry checks, throttle to every 6 hours to avoid querying the // API/DB on every monitor loop. The certificate-service renews certs // 45 days before expiry, so checking every 6 hours is plenty frequent // to pick up renewed certs promptly. const renewalCheckIntervalMs = 6 * 60 * 60 * 1000; // 6 hours if (timeSinceLastFetch > renewalCheckIntervalMs) { // Check non-wildcard certs for expiry (within 45 days to match // the server-side renewal window in certificate-service) for (const domain of domainsNeedingCerts) { const localState = this.lastLocalCertificateState.get(domain); if (localState?.expiresAt) { const nowInSeconds = Math.floor(Date.now() / 1000); const secondsUntilExpiry = localState.expiresAt - nowInSeconds; const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24); if (daysUntilExpiry < 45) { logger.info( `Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)` ); return true; } } } // Also check wildcard certificates for expiry. These are not // included in domainsNeedingCerts since their subdomains are // filtered out, so we must check them separately. for (const [certDomain, state] of this .lastLocalCertificateState) { if ( state.exists && state.wildcard && state.expiresAt ) { const nowInSeconds = Math.floor(Date.now() / 1000); const secondsUntilExpiry = state.expiresAt - nowInSeconds; const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24); if (daysUntilExpiry < 45) { logger.info( `Fetching certificates due to upcoming expiry for wildcard cert ${certDomain} (${Math.round(daysUntilExpiry)} days remaining)` ); return true; } } } } return false; } /** * Main monitoring logic */ lastActiveDomains: Set = new Set(); public async HandleTraefikConfig(): Promise { try { // Get all active domains for this exit node via HTTP call const getTraefikConfig = await this.internalGetTraefikConfig(); if (!getTraefikConfig) { logger.error( "Failed to fetch active domains from traefik config" ); return; } const { domains, traefikConfig } = getTraefikConfig; // Add static domains from config // const staticDomains = [config.getRawConfig().app.dashboard_url]; // staticDomains.forEach((domain) => domains.add(domain)); // Log if domains changed if ( this.lastActiveDomains.size !== domains.size || !Array.from(this.lastActiveDomains).every((domain) => domains.has(domain) ) ) { logger.info( `Active domains changed for exit node: ${Array.from(domains).join(", ")}` ); this.lastActiveDomains = new Set(domains); } if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") { // Scan current local certificate state this.lastLocalCertificateState = await this.scanLocalCertificateState(); // Only fetch certificates if needed (domain changes, missing certs, or daily renewal check) let validCertificates: Array<{ id: number; domain: string; wildcard: boolean | null; certFile: string | null; keyFile: string | null; expiresAt: number | null; updatedAt?: number | null; }> = []; if (this.shouldFetchCertificates(domains)) { // Filter out domains that are already covered by wildcard certificates const domainsToFetch = new Set(); for (const domain of domains) { if ( !isDomainCoveredByWildcard( domain, this.lastLocalCertificateState ) ) { domainsToFetch.add(domain); } else { logger.debug( `Domain ${domain} is covered by existing wildcard certificate, skipping fetch` ); } } // Also include wildcard cert base domains that are // expiring or expired so they get re-fetched even though // their subdomains were filtered out above. for (const [certDomain, state] of this .lastLocalCertificateState) { if ( state.exists && state.wildcard && state.expiresAt ) { const nowInSeconds = Math.floor( Date.now() / 1000 ); const secondsUntilExpiry = state.expiresAt - nowInSeconds; const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24); if (daysUntilExpiry < 45) { domainsToFetch.add(certDomain); logger.info( `Including expiring wildcard cert domain ${certDomain} in fetch (${Math.round(daysUntilExpiry)} days remaining)` ); } } } if (domainsToFetch.size > 0) { // Get valid certificates for domains not covered by wildcards validCertificates = await getValidCertificatesForDomains( domainsToFetch ); this.lastCertificateFetch = new Date(); this.lastKnownDomains = new Set(domains); logger.info( `Fetched ${validCertificates.length} certificates from remote (${domains.size - domainsToFetch.size} domains covered by wildcards)` ); // Download and decrypt new certificates await this.processValidCertificates(validCertificates); } else { logger.info( "All domains are covered by existing wildcard certificates, no fetch needed" ); this.lastCertificateFetch = new Date(); this.lastKnownDomains = new Set(domains); } // Always ensure all existing certificates (including wildcards) are in the config await this.updateDynamicConfigFromLocalCerts(domains); } else { const timeSinceLastFetch = this.lastCertificateFetch ? Math.round( (Date.now() - this.lastCertificateFetch.getTime()) / (1000 * 60) ) : 0; // logger.debug( // `Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)` // ); // Still need to ensure config is up to date with existing certificates await this.updateDynamicConfigFromLocalCerts(domains); } // Clean up certificates for domains no longer in use await this.cleanupUnusedCertificates(domains); // wait 1 second for traefik to pick up the new certificates await new Promise((resolve) => setTimeout(resolve, 500)); } // Write traefik config as YAML to a second dynamic config file if changed await this.writeTraefikDynamicConfig(traefikConfig); // Send domains to SNI proxy try { let exitNode; if (config.getRawConfig().gerbil.exit_node_name) { const exitNodeName = config.getRawConfig().gerbil.exit_node_name!; [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.name, exitNodeName)) .limit(1); } else { [exitNode] = await db.select().from(exitNodes).limit(1); } if (exitNode) { await sendToExitNode(exitNode, { localPath: "/update-local-snis", method: "POST", data: { fullDomains: Array.from(domains) } }); } else { logger.error( "No exit node found. Has gerbil registered yet?" ); } } catch (err) { logger.error("Failed to post domains to SNI proxy:", err); } // Update active domains tracking this.activeDomains = domains; } catch (error) { logger.error("Error in traefik config monitoring cycle:", error); } } /** * Get all domains currently in use from traefik config API */ private async internalGetTraefikConfig(): Promise<{ domains: Set; traefikConfig: any; } | null> { let traefikConfig; try { const currentExitNode = await getCurrentExitNodeId(); // logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`); traefikConfig = await getTraefikConfig( // this is called by the local exit node to get its own config currentExitNode, config.getRawConfig().traefik.site_types, build == "oss", // filter out the namespace domains in open source build != "oss", // generate the login pages on the cloud and hybrid, build == "saas" ? false : config.getRawConfig().traefik.allow_raw_resources // dont allow raw resources on saas otherwise use config ); const domains = new Set(); if (traefikConfig?.http?.routers) { for (const router of Object.values( traefikConfig.http.routers )) { if (router.rule && typeof router.rule === "string") { // Match Host(`domain`) const match = router.rule.match(/Host\(`([^`]+)`\)/); if (match && match[1]) { domains.add(match[1]); } } } } // logger.debug( // `Successfully retrieved traefik config: ${JSON.stringify(traefikConfig)}` // ); const badgerMiddlewareName = "badger"; if (traefikConfig?.http?.middlewares) { traefikConfig.http.middlewares[badgerMiddlewareName] = { plugin: { [badgerMiddlewareName]: { apiBaseUrl: new URL( "/api/v1", `http://${ config.getRawConfig().server .internal_hostname }:${config.getRawConfig().server.internal_port}` ).href, userSessionCookieName: config.getRawConfig().server .session_cookie_name, // deprecated accessTokenQueryParam: config.getRawConfig().server .resource_access_token_param, resourceSessionRequestParam: config.getRawConfig().server .resource_session_request_param } } }; } // tcp: // serversTransports: // pp-transport-v1: // proxyProtocol: // version: 1 // pp-transport-v2: // proxyProtocol: // version: 2 if (build != "saas") { // add the serversTransports section if not present if (traefikConfig.tcp && !traefikConfig.tcp.serversTransports) { traefikConfig.tcp.serversTransports = { "pp-transport-v1": { proxyProtocol: { version: 1 } }, "pp-transport-v2": { proxyProtocol: { version: 2 } } }; } } return { domains, traefikConfig }; } catch (error) { // pull data out of the axios error to log if (axios.isAxiosError(error)) { logger.error("Error fetching traefik config:", { message: error.message, code: error.code, status: error.response?.status, statusText: error.response?.statusText, url: error.config?.url, method: error.config?.method }); } else { logger.error("Error fetching traefik config:", error); } return null; } } /** * Write traefik config as YAML to a second dynamic config file if changed */ private async writeTraefikDynamicConfig(traefikConfig: any): Promise { const traefikDynamicConfigPath = config.getRawConfig().traefik.dynamic_router_config_path; let shouldWrite = false; let oldJson = ""; if (fs.existsSync(traefikDynamicConfigPath)) { try { const oldContent = fs.readFileSync( traefikDynamicConfigPath, "utf8" ); // Try to parse as YAML then JSON.stringify for comparison const oldObj = yaml.load(oldContent); oldJson = JSON.stringify(oldObj); } catch { oldJson = ""; } } const newJson = JSON.stringify(traefikConfig); if (oldJson !== newJson) { shouldWrite = true; } if (shouldWrite) { try { fs.writeFileSync( traefikDynamicConfigPath, yaml.dump(traefikConfig, { noRefs: true }), "utf8" ); logger.info("Traefik dynamic config updated"); } catch (err) { logger.error("Failed to write traefik dynamic config:", err); } } } /** * Update dynamic config from existing local certificates without fetching from remote */ private async updateDynamicConfigFromLocalCerts( domains: Set ): Promise { const dynamicConfigPath = config.getRawConfig().traefik.dynamic_cert_config_path; // Load existing dynamic config if it exists, otherwise initialize let dynamicConfig: any = { tls: { certificates: [] } }; if (fs.existsSync(dynamicConfigPath)) { try { const fileContent = fs.readFileSync(dynamicConfigPath, "utf8"); dynamicConfig = yaml.load(fileContent) || dynamicConfig; if (!dynamicConfig.tls) dynamicConfig.tls = { certificates: [] }; if (!Array.isArray(dynamicConfig.tls.certificates)) { dynamicConfig.tls.certificates = []; } } catch (err) { logger.error("Failed to load existing dynamic config:", err); } } // Keep a copy of the original config for comparison const originalConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); // Clear existing certificates and rebuild from local state dynamicConfig.tls.certificates = []; // Keep track of certificates we've already added to avoid duplicates const addedCertPaths = new Set(); for (const domain of domains) { // First, try to find an exact match certificate const localState = this.lastLocalCertificateState.get(domain); if (localState && localState.exists) { const domainDir = path.join( config.getRawConfig().traefik.certificates_path, domain ); const certPath = path.join(domainDir, "cert.pem"); const keyPath = path.join(domainDir, "key.pem"); if (!addedCertPaths.has(certPath)) { const certEntry = { certFile: certPath, keyFile: keyPath }; dynamicConfig.tls.certificates.push(certEntry); addedCertPaths.add(certPath); } continue; } // If no exact match, check for wildcard certificates that cover this domain for (const [certDomain, certState] of this .lastLocalCertificateState) { if (certState.exists && certState.wildcard) { // Check if this wildcard certificate covers the domain if (domain.endsWith("." + certDomain)) { // Verify it's only one level deep (wildcard only covers one level) const prefix = domain.substring( 0, domain.length - ("." + certDomain).length ); if (!prefix.includes(".")) { const domainDir = path.join( config.getRawConfig().traefik.certificates_path, certDomain ); const certPath = path.join(domainDir, "cert.pem"); const keyPath = path.join(domainDir, "key.pem"); if (!addedCertPaths.has(certPath)) { const certEntry = { certFile: certPath, keyFile: keyPath }; dynamicConfig.tls.certificates.push(certEntry); addedCertPaths.add(certPath); } break; // Found a wildcard that covers this domain } } } } } // Only write the config if it has changed const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); if (newConfigYaml !== originalConfigYaml) { fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8"); logger.info("Dynamic cert config updated from local certificates"); } } /** * Process valid certificates - download and decrypt them */ private async processValidCertificates( validCertificates: Array<{ id: number; domain: string; wildcard: boolean | null; certFile: string | null; keyFile: string | null; expiresAt: number | null; updatedAt?: number | null; }> ): Promise { const dynamicConfigPath = config.getRawConfig().traefik.dynamic_cert_config_path; // Load existing dynamic config if it exists, otherwise initialize let dynamicConfig: any = { tls: { certificates: [] } }; if (fs.existsSync(dynamicConfigPath)) { try { const fileContent = fs.readFileSync(dynamicConfigPath, "utf8"); dynamicConfig = yaml.load(fileContent) || dynamicConfig; if (!dynamicConfig.tls) dynamicConfig.tls = { certificates: [] }; if (!Array.isArray(dynamicConfig.tls.certificates)) { dynamicConfig.tls.certificates = []; } } catch (err) { logger.error("Failed to load existing dynamic config:", err); } } // Keep a copy of the original config for comparison const originalConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); for (const cert of validCertificates) { try { if ( !cert.certFile || !cert.keyFile || cert.certFile.length === 0 || cert.keyFile.length === 0 ) { logger.warn( `Certificate for domain ${cert.domain} is missing cert or key file` ); continue; } const domainDir = path.join( config.getRawConfig().traefik.certificates_path, cert.domain ); await this.ensureDirectoryExists(domainDir); const certPath = path.join(domainDir, "cert.pem"); const keyPath = path.join(domainDir, "key.pem"); const lastUpdatePath = path.join(domainDir, ".last_update"); // Check if we need to update the certificate const shouldUpdate = await this.shouldUpdateCertificate( cert, certPath, keyPath, lastUpdatePath ); if (shouldUpdate) { logger.info( `Processing certificate for domain: ${cert.domain}` ); fs.writeFileSync(certPath, cert.certFile, "utf8"); fs.writeFileSync(keyPath, cert.keyFile, "utf8"); // Set appropriate permissions (readable by owner only for key file) fs.chmodSync(certPath, 0o644); fs.chmodSync(keyPath, 0o600); // Write/update .last_update file with current timestamp fs.writeFileSync( lastUpdatePath, new Date().toISOString(), "utf8" ); // Check if this is a wildcard certificate and store it const wildcardPath = path.join(domainDir, ".wildcard"); fs.writeFileSync( wildcardPath, cert.wildcard ? "true" : "false", "utf8" ); logger.info( `Certificate updated for domain: ${cert.domain}${cert.wildcard ? " (wildcard)" : ""}` ); } // Always update expiry tracking when we fetch a certificate, // even if the cert content didn't change if (cert.expiresAt) { const expiresAtPath = path.join(domainDir, ".expires_at"); fs.writeFileSync( expiresAtPath, cert.expiresAt.toString(), "utf8" ); } // Update local state tracking this.lastLocalCertificateState.set(cert.domain, { exists: true, lastModified: Math.floor(Date.now() / 1000), expiresAt: cert.expiresAt, wildcard: cert.wildcard }); // Always ensure the config entry exists and is up to date const certEntry = { certFile: certPath, keyFile: keyPath }; // Remove any existing entry for this cert/key path dynamicConfig.tls.certificates = dynamicConfig.tls.certificates.filter( (entry: any) => entry.certFile !== certEntry.certFile || entry.keyFile !== certEntry.keyFile ); dynamicConfig.tls.certificates.push(certEntry); } catch (error) { logger.error( `Error processing certificate for domain ${cert.domain}:`, error ); } } // Only write the config if it has changed const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); if (newConfigYaml !== originalConfigYaml) { fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8"); logger.info("Dynamic cert config updated"); } } /** * Check if certificate should be updated */ private async shouldUpdateCertificate( cert: { id: number; domain: string; expiresAt: number | null; updatedAt?: number | null; }, certPath: string, keyPath: string, lastUpdatePath: string ): Promise { try { // If files don't exist, we need to create them const certExists = await this.fileExists(certPath); const keyExists = await this.fileExists(keyPath); const lastUpdateExists = await this.fileExists(lastUpdatePath); if (!certExists || !keyExists || !lastUpdateExists) { return true; } // Read last update time from .last_update file let lastUpdateTime: number | null = null; try { const lastUpdateStr = fs .readFileSync(lastUpdatePath, "utf8") .trim(); lastUpdateTime = Math.floor( new Date(lastUpdateStr).getTime() / 1000 ); } catch { lastUpdateTime = null; } // Use updatedAt from cert, fallback to expiresAt if not present const dbUpdateTime = cert.updatedAt ?? cert.expiresAt; if (!dbUpdateTime) { // If no update time in DB, always update return true; } // If DB updatedAt is newer than last update file, update if (!lastUpdateTime || dbUpdateTime > lastUpdateTime) { return true; } return false; } catch (error) { logger.error( `Error checking certificate update status for ${cert.domain}:`, error ); return true; // When in doubt, update } } /** * Clean up certificates for domains no longer in use */ private async cleanupUnusedCertificates( currentActiveDomains: Set ): Promise { try { const certsPath = config.getRawConfig().traefik.certificates_path; const dynamicConfigPath = config.getRawConfig().traefik.dynamic_cert_config_path; // Load existing dynamic config if it exists let dynamicConfig: any = { tls: { certificates: [] } }; if (fs.existsSync(dynamicConfigPath)) { try { const fileContent = fs.readFileSync( dynamicConfigPath, "utf8" ); dynamicConfig = yaml.load(fileContent) || dynamicConfig; if (!dynamicConfig.tls) dynamicConfig.tls = { certificates: [] }; if (!Array.isArray(dynamicConfig.tls.certificates)) { dynamicConfig.tls.certificates = []; } } catch (err) { logger.error( "Failed to load existing dynamic config:", err ); } } const certDirs = fs.readdirSync(certsPath, { withFileTypes: true }); let configChanged = false; for (const dirent of certDirs) { if (!dirent.isDirectory()) continue; const dirName = dirent.name; // Only delete if NO current domain is exactly the same or ends with `.${dirName}` const shouldDelete = !Array.from(currentActiveDomains).some( (domain) => domain === dirName || domain.endsWith(`.${dirName}`) ); if (shouldDelete) { const domainDir = path.join(certsPath, dirName); logger.info( `Cleaning up unused certificate directory: ${dirName}` ); fs.rmSync(domainDir, { recursive: true, force: true }); // Remove from local state tracking this.lastLocalCertificateState.delete(dirName); // Remove from dynamic config const certFilePath = path.join(domainDir, "cert.pem"); const keyFilePath = path.join(domainDir, "key.pem"); const before = dynamicConfig.tls.certificates.length; dynamicConfig.tls.certificates = dynamicConfig.tls.certificates.filter( (entry: any) => entry.certFile !== certFilePath && entry.keyFile !== keyFilePath ); if (dynamicConfig.tls.certificates.length !== before) { configChanged = true; } } } if (configChanged) { try { fs.writeFileSync( dynamicConfigPath, yaml.dump(dynamicConfig, { noRefs: true }), "utf8" ); logger.info("Dynamic config updated after cleanup"); } catch (err) { logger.error( "Failed to update dynamic config after cleanup:", err ); } } } catch (error) { logger.error("Error during certificate cleanup:", error); } } /** * Ensure directory exists */ private async ensureDirectoryExists(dirPath: string): Promise { try { fs.mkdirSync(dirPath, { recursive: true }); } catch (error) { logger.error(`Error creating directory ${dirPath}:`, error); throw error; } } /** * Check if file exists */ private async fileExists(filePath: string): Promise { try { fs.accessSync(filePath); return true; } catch { return false; } } /** * Force a certificate refresh regardless of cache state */ public async forceCertificateRefresh(): Promise { logger.info("Forcing certificate refresh"); this.lastCertificateFetch = null; this.lastKnownDomains = new Set(); await this.HandleTraefikConfig(); } /** * Get current status */ getStatus(): { isRunning: boolean; activeDomains: string[]; monitorInterval: number; lastCertificateFetch: Date | null; localCertificateCount: number; wildcardCertificates: string[]; domainsCoveredByWildcards: string[]; } { const wildcardCertificates: string[] = []; const domainsCoveredByWildcards: string[] = []; // Find wildcard certificates for (const [domain, state] of this.lastLocalCertificateState) { if (state.exists && state.wildcard) { wildcardCertificates.push(domain); } } // Find domains covered by wildcards for (const domain of this.activeDomains) { if ( isDomainCoveredByWildcard( domain, this.lastLocalCertificateState ) ) { domainsCoveredByWildcards.push(domain); } } return { isRunning: this.isRunning, activeDomains: Array.from(this.activeDomains), monitorInterval: config.getRawConfig().traefik.monitor_interval || 5000, lastCertificateFetch: this.lastCertificateFetch, localCertificateCount: this.lastLocalCertificateState.size, wildcardCertificates, domainsCoveredByWildcards }; } } /** * Check if a domain is covered by existing wildcard certificates */ export function isDomainCoveredByWildcard( domain: string, lastLocalCertificateState: Map< string, { exists: boolean; wildcard: boolean | null } > ): boolean { for (const [certDomain, state] of lastLocalCertificateState) { if (state.exists && state.wildcard) { // If stored as example.com but is wildcard, check subdomains if (domain.endsWith("." + certDomain)) { // Check that it's only one level deep (wildcard only covers one level) const prefix = domain.substring( 0, domain.length - ("." + certDomain).length ); // If prefix contains a dot, it's more than one level deep if (!prefix.includes(".")) { return true; } } } } return false; } ================================================ FILE: server/lib/traefik/getTraefikConfig.ts ================================================ import { db, targetHealthCheck, domains } from "@server/db"; import { and, eq, inArray, or, isNull, ne, isNotNull, desc, sql } from "drizzle-orm"; import logger from "@server/logger"; import config from "@server/lib/config"; import { resources, sites, Target, targets } from "@server/db"; import createPathRewriteMiddleware from "./middleware"; import { sanitize, encodePath, validatePathRewriteConfig } from "./utils"; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; // Define extended target type with site information type TargetWithSite = Target & { resourceId: number; targetId: number; ip: string | null; method: string | null; port: number | null; internalPort: number | null; enabled: boolean; health: string | null; site: { siteId: number; type: string; subnet: string | null; exitNodeId: number | null; online: boolean; }; }; export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE allowRawResources = true, allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE ): Promise { // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources const resourcesWithTargetsAndSites = await db .select({ // Resource fields resourceId: resources.resourceId, resourceName: resources.name, fullDomain: resources.fullDomain, ssl: resources.ssl, http: resources.http, proxyPort: resources.proxyPort, protocol: resources.protocol, subdomain: resources.subdomain, domainId: resources.domainId, enabled: resources.enabled, stickySession: resources.stickySession, tlsServerName: resources.tlsServerName, setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, headers: resources.headers, proxyProtocol: resources.proxyProtocol, proxyProtocolVersion: resources.proxyProtocolVersion, // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, ip: targets.ip, method: targets.method, port: targets.port, internalPort: targets.internalPort, hcHealth: targetHealthCheck.hcHealth, path: targets.path, pathMatchType: targets.pathMatchType, rewritePath: targets.rewritePath, rewritePathType: targets.rewritePathType, priority: targets.priority, // Site fields siteId: sites.siteId, siteType: sites.type, siteOnline: sites.online, subnet: sites.subnet, exitNodeId: sites.exitNodeId, // Domain cert resolver fields domainCertResolver: domains.certResolver, preferWildcardCert: domains.preferWildcardCert }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) .leftJoin(domains, eq(domains.domainId, resources.domainId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) ) .where( and( eq(targets.enabled, true), eq(resources.enabled, true), or( eq(sites.exitNodeId, exitNodeId), and( isNull(sites.exitNodeId), sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, // only allow local sites if "local" is in siteTypes eq(sites.type, "local") ) ), inArray(sites.type, siteTypes), allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true : eq(resources.http, true) ) ) .orderBy(desc(targets.priority), targets.targetId); // stable ordering // Group by resource and include targets with their unique site data const resourcesMap = new Map(); resourcesWithTargetsAndSites.forEach((row) => { const resourceId = row.resourceId; const resourceName = sanitize(row.resourceName) || ""; const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b") const pathMatchType = row.pathMatchType || ""; const rewritePath = row.rewritePath || ""; const rewritePathType = row.rewritePathType || ""; const priority = row.priority ?? 100; // Create a unique key combining resourceId, path config, and rewrite config const pathKey = [ targetPath, pathMatchType, rewritePath, rewritePathType ] .filter(Boolean) .join("-"); const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); const key = sanitize(mapKey); if (!resourcesMap.has(mapKey)) { const validation = validatePathRewriteConfig( row.path, row.pathMatchType, row.rewritePath, row.rewritePathType ); if (!validation.isValid) { logger.error( `Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}` ); return; } resourcesMap.set(mapKey, { resourceId: row.resourceId, name: resourceName, key: key, fullDomain: row.fullDomain, ssl: row.ssl, http: row.http, proxyPort: row.proxyPort, protocol: row.protocol, subdomain: row.subdomain, domainId: row.domainId, enabled: row.enabled, stickySession: row.stickySession, tlsServerName: row.tlsServerName, setHostHeader: row.setHostHeader, enableProxy: row.enableProxy, targets: [], headers: row.headers, proxyProtocol: row.proxyProtocol, proxyProtocolVersion: row.proxyProtocolVersion ?? 1, path: row.path, // the targets will all have the same path pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, priority: priority, // Store domain cert resolver fields domainCertResolver: row.domainCertResolver, preferWildcardCert: row.preferWildcardCert }); } resourcesMap.get(mapKey).targets.push({ resourceId: row.resourceId, targetId: row.targetId, ip: row.ip, method: row.method, port: row.port, internalPort: row.internalPort, enabled: row.targetEnabled, health: row.hcHealth, site: { siteId: row.siteId, type: row.siteType, subnet: row.subnet, exitNodeId: row.exitNodeId, online: row.siteOnline } }); }); // make sure we have at least one resource if (resourcesMap.size === 0) { return {}; } const config_output: any = { http: { middlewares: { [redirectHttpsMiddlewareName]: { redirectScheme: { scheme: "https" } } } } }; // get the key and the resource for (const [, resource] of resourcesMap.entries()) { const targets = resource.targets as TargetWithSite[]; const key = resource.key; const routerName = `${key}-${resource.name}-router`; const serviceName = `${key}-${resource.name}-service`; const fullDomain = `${resource.fullDomain}`; const transportName = `${key}-transport`; const headersMiddlewareName = `${key}-headers-middleware`; if (!resource.enabled) { continue; } if (resource.http) { if (!resource.domainId || !resource.fullDomain) { continue; } // Initialize routers and services if they don't exist if (!config_output.http.routers) { config_output.http.routers = {}; } if (!config_output.http.services) { config_output.http.services = {}; } const domainParts = fullDomain.split("."); let wildCard; if (domainParts.length <= 2) { wildCard = `*.${domainParts.join(".")}`; } else { wildCard = `*.${domainParts.slice(1).join(".")}`; } if (!resource.subdomain) { wildCard = resource.fullDomain; } const globalDefaultResolver = config.getRawConfig().traefik.cert_resolver; const globalDefaultPreferWildcard = config.getRawConfig().traefik.prefer_wildcard_cert; const domainCertResolver = resource.domainCertResolver; const preferWildcardCert = resource.preferWildcardCert; let resolverName: string | undefined; let preferWildcard: boolean | undefined; // Handle both letsencrypt & custom cases if (domainCertResolver) { resolverName = domainCertResolver.trim(); } else { resolverName = globalDefaultResolver; } if ( preferWildcardCert !== undefined && preferWildcardCert !== null ) { preferWildcard = preferWildcardCert; } else { preferWildcard = globalDefaultPreferWildcard; } const tls = { certResolver: resolverName, ...(preferWildcard ? { domains: [ { main: wildCard } ] } : {}) }; const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [ badgerMiddlewareName, ...additionalMiddlewares ]; // Handle path rewriting middleware if ( resource.rewritePath !== null && resource.path !== null && resource.pathMatchType && resource.rewritePathType ) { // Create a unique middleware name const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`; try { const rewriteResult = createPathRewriteMiddleware( rewriteMiddlewareName, resource.path, resource.pathMatchType, resource.rewritePath, resource.rewritePathType ); // Initialize middlewares object if it doesn't exist if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } // the middleware to the config Object.assign( config_output.http.middlewares, rewriteResult.middlewares ); // middlewares to the router middleware chain if (rewriteResult.chain) { // For chained middlewares (like stripPrefix + addPrefix) routerMiddlewares.push(...rewriteResult.chain); } else { // Single middleware routerMiddlewares.push(rewriteMiddlewareName); } // logger.debug( // `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})` // ); } catch (error) { logger.error( `Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}` ); } } // Handle custom headers middleware if (resource.headers || resource.setHostHeader) { const headersObj: { [key: string]: string } = {}; if (resource.headers) { let headersArr: { name: string; value: string }[] = []; try { headersArr = JSON.parse(resource.headers) as { name: string; value: string; }[]; } catch (e) { logger.warn( `Failed to parse headers for resource ${resource.resourceId}: ${e}` ); } headersArr.forEach((header) => { headersObj[header.name] = header.value; }); } if (resource.setHostHeader) { headersObj["Host"] = resource.setHostHeader; } if (Object.keys(headersObj).length > 0) { if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } config_output.http.middlewares[headersMiddlewareName] = { headers: { customRequestHeaders: headersObj } }; routerMiddlewares.push(headersMiddlewareName); } } // Build routing rules let rule = `Host(\`${fullDomain}\`)`; // priority logic let priority: number; if (resource.priority && resource.priority != 100) { priority = resource.priority; } else { priority = 100; if (resource.path && resource.pathMatchType) { priority += 10; if (resource.pathMatchType === "exact") { priority += 5; } else if (resource.pathMatchType === "prefix") { priority += 3; } else if (resource.pathMatchType === "regex") { priority += 2; } if (resource.path === "/") { priority = 1; // lowest for catch-all } } } if (resource.path && resource.pathMatchType) { // priority += 1; // add path to rule based on match type let path = resource.path; // if the path doesn't start with a /, add it if (!path.startsWith("/")) { path = `/${path}`; } if (resource.pathMatchType === "exact") { rule += ` && Path(\`${path}\`)`; } else if (resource.pathMatchType === "prefix") { rule += ` && PathPrefix(\`${path}\`)`; } else if (resource.pathMatchType === "regex") { rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex } } config_output.http.routers![routerName] = { entryPoints: [ resource.ssl ? config.getRawConfig().traefik.https_entrypoint : config.getRawConfig().traefik.http_entrypoint ], middlewares: routerMiddlewares, service: serviceName, rule: rule, priority: priority, ...(resource.ssl ? { tls } : {}) }; if (resource.ssl) { config_output.http.routers![routerName + "-redirect"] = { entryPoints: [ config.getRawConfig().traefik.http_entrypoint ], middlewares: [redirectHttpsMiddlewareName], service: serviceName, rule: rule, priority: priority }; } config_output.http.services![serviceName] = { loadBalancer: { servers: (() => { // Check if any sites are online // THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK // EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE // RECEIVE BANDWIDTH ENDPOINT. // TODO: HOW TO HANDLE ^^^^^^ BETTER const anySitesOnline = targets.some( (target) => target.site.online || target.site.type === "local" || target.site.type === "wireguard" ); return ( targets .filter((target) => { if (!target.enabled) { return false; } if (target.health == "unhealthy") { return false; } // If any sites are online, exclude offline sites if (anySitesOnline && !target.site.online) { return false; } if ( target.site.type === "local" || target.site.type === "wireguard" ) { if ( !target.ip || !target.port || !target.method ) { return false; } } else if (target.site.type === "newt") { if ( !target.internalPort || !target.method || !target.site.subnet ) { return false; } } return true; }) .map((target) => { if ( target.site.type === "local" || target.site.type === "wireguard" ) { return { url: `${target.method}://${target.ip}:${target.port}` }; } else if (target.site.type === "newt") { const ip = target.site.subnet!.split("/")[0]; return { url: `${target.method}://${ip}:${target.internalPort}` }; } }) // filter out duplicates .filter( (v, i, a) => a.findIndex( (t) => t && v && t.url === v.url ) === i ) ); })(), ...(resource.stickySession ? { sticky: { cookie: { name: "p_sticky", // TODO: make this configurable via config.yml like other cookies secure: resource.ssl, httpOnly: true } } } : {}) } }; // Add the serversTransport if TLS server name is provided if (resource.tlsServerName) { if (!config_output.http.serversTransports) { config_output.http.serversTransports = {}; } config_output.http.serversTransports![transportName] = { serverName: resource.tlsServerName, //unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings // if defined in the static config and here. if not set, self-signed certs won't work insecureSkipVerify: true }; config_output.http.services![ serviceName ].loadBalancer.serversTransport = transportName; } } else { // Non-HTTP (TCP/UDP) configuration if (!resource.enableProxy || !resource.proxyPort) { continue; } const protocol = resource.protocol.toLowerCase(); const port = resource.proxyPort; if (!port) { continue; } if (!config_output[protocol]) { config_output[protocol] = { routers: {}, services: {} }; } config_output[protocol].routers[routerName] = { entryPoints: [`${protocol}-${port}`], service: serviceName, ...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {}) }; const ppPrefix = config.getRawConfig().traefik.pp_transport_prefix; config_output[protocol].services[serviceName] = { loadBalancer: { servers: (() => { // Check if any sites are online const anySitesOnline = targets.some( (target) => target.site.online || target.site.type === "local" || target.site.type === "wireguard" ); return targets .filter((target) => { if (!target.enabled) { return false; } // If any sites are online, exclude offline sites if (anySitesOnline && !target.site.online) { return false; } if ( target.site.type === "local" || target.site.type === "wireguard" ) { if (!target.ip || !target.port) { return false; } } else if (target.site.type === "newt") { if ( !target.internalPort || !target.site.subnet ) { return false; } } return true; }) .map((target) => { if ( target.site.type === "local" || target.site.type === "wireguard" ) { return { address: `${target.ip}:${target.port}` }; } else if (target.site.type === "newt") { const ip = target.site.subnet!.split("/")[0]; return { address: `${ip}:${target.internalPort}` }; } }); })(), ...(resource.proxyProtocol && protocol == "tcp" ? { serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues? } : {}), ...(resource.stickySession ? { sticky: { ipStrategy: { depth: 0, sourcePort: true } } } : {}) } }; } } return config_output; } ================================================ FILE: server/lib/traefik/index.ts ================================================ export * from "./getTraefikConfig"; ================================================ FILE: server/lib/traefik/middleware.ts ================================================ import logger from "@server/logger"; export default function createPathRewriteMiddleware( middlewareName: string, path: string, pathMatchType: string, rewritePath: string, rewritePathType: string ): { middlewares: { [key: string]: any }; chain?: string[] } { const middlewares: { [key: string]: any } = {}; if (pathMatchType !== "regex" && !path.startsWith("/")) { path = `/${path}`; } if ( rewritePathType !== "regex" && rewritePath !== "" && !rewritePath.startsWith("/") ) { rewritePath = `/${rewritePath}`; } switch (rewritePathType) { case "exact": // Replace the path with the exact rewrite path const exactPattern = `^${escapeRegex(path)}$`; middlewares[middlewareName] = { replacePathRegex: { regex: exactPattern, replacement: rewritePath } }; break; case "prefix": // Replace matched prefix with new prefix, preserve the rest switch (pathMatchType) { case "prefix": middlewares[middlewareName] = { replacePathRegex: { regex: `^${escapeRegex(path)}(.*)`, replacement: `${rewritePath}$1` } }; break; case "exact": middlewares[middlewareName] = { replacePathRegex: { regex: `^${escapeRegex(path)}$`, replacement: rewritePath } }; break; case "regex": // For regex path matching with prefix rewrite, we assume the regex has capture groups middlewares[middlewareName] = { replacePathRegex: { regex: path, replacement: rewritePath } }; break; } break; case "regex": // Use advanced regex replacement - works with any match type let regexPattern: string; if (pathMatchType === "regex") { regexPattern = path; } else if (pathMatchType === "prefix") { regexPattern = `^${escapeRegex(path)}(.*)`; } else { // exact regexPattern = `^${escapeRegex(path)}$`; } middlewares[middlewareName] = { replacePathRegex: { regex: regexPattern, replacement: rewritePath } }; break; case "stripPrefix": // Strip the matched prefix and optionally add new path if (pathMatchType === "prefix") { middlewares[middlewareName] = { stripPrefix: { prefixes: [path] } }; // If rewritePath is provided and not empty, add it as a prefix after stripping if (rewritePath && rewritePath !== "" && rewritePath !== "/") { const addPrefixMiddlewareName = `addprefix-${middlewareName.replace("rewrite-", "")}`; middlewares[addPrefixMiddlewareName] = { addPrefix: { prefix: rewritePath } }; return { middlewares, chain: [middlewareName, addPrefixMiddlewareName] }; } } else { // For exact and regex matches, use replacePathRegex to strip let regexPattern: string; if (pathMatchType === "exact") { regexPattern = `^${escapeRegex(path)}$`; } else if (pathMatchType === "regex") { regexPattern = path; } else { regexPattern = `^${escapeRegex(path)}`; } const replacement = rewritePath || "/"; middlewares[middlewareName] = { replacePathRegex: { regex: regexPattern, replacement: replacement } }; } break; default: logger.error(`Unknown rewritePathType: ${rewritePathType}`); throw new Error(`Unknown rewritePathType: ${rewritePathType}`); } return { middlewares }; } function escapeRegex(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } ================================================ FILE: server/lib/traefik/pathEncoding.test.ts ================================================ import { assertEquals } from "../../../test/assert"; // ── Pure function copies (inlined to avoid pulling in server dependencies) ── function sanitize(input: string | null | undefined): string | undefined { if (!input) return undefined; if (input.length > 50) { input = input.substring(0, 50); } return input .replace(/[^a-zA-Z0-9-]/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } function encodePath(path: string | null | undefined): string { if (!path) return ""; return path.replace(/[^a-zA-Z0-9]/g, (ch) => { return ch.charCodeAt(0).toString(16); }); } // ── Helpers ────────────────────────────────────────────────────────── /** * Exact replica of the OLD key computation from upstream main. * Uses sanitize() for paths — this is what had the collision bug. */ function oldKeyComputation( resourceId: number, path: string | null, pathMatchType: string | null, rewritePath: string | null, rewritePathType: string | null ): string { const targetPath = sanitize(path) || ""; const pmt = pathMatchType || ""; const rp = rewritePath || ""; const rpt = rewritePathType || ""; const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-"); const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); return sanitize(mapKey) || ""; } /** * Replica of the NEW key computation from our fix. * Uses encodePath() for paths — collision-free. */ function newKeyComputation( resourceId: number, path: string | null, pathMatchType: string | null, rewritePath: string | null, rewritePathType: string | null ): string { const targetPath = encodePath(path); const pmt = pathMatchType || ""; const rp = rewritePath || ""; const rpt = rewritePathType || ""; const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-"); const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); return sanitize(mapKey) || ""; } // ── Tests ──────────────────────────────────────────────────────────── function runTests() { console.log("Running path encoding tests...\n"); let passed = 0; // ── encodePath unit tests ──────────────────────────────────────── // Test 1: null/undefined/empty { assertEquals(encodePath(null), "", "null should return empty"); assertEquals( encodePath(undefined), "", "undefined should return empty" ); assertEquals(encodePath(""), "", "empty string should return empty"); console.log(" PASS: encodePath handles null/undefined/empty"); passed++; } // Test 2: root path { assertEquals(encodePath("/"), "2f", "/ should encode to 2f"); console.log(" PASS: encodePath encodes root path"); passed++; } // Test 3: alphanumeric passthrough { assertEquals(encodePath("/api"), "2fapi", "/api encodes slash only"); assertEquals(encodePath("/v1"), "2fv1", "/v1 encodes slash only"); assertEquals(encodePath("abc"), "abc", "plain alpha passes through"); console.log(" PASS: encodePath preserves alphanumeric chars"); passed++; } // Test 4: all special chars produce unique hex { const paths = ["/a/b", "/a-b", "/a.b", "/a_b", "/a b"]; const results = paths.map((p) => encodePath(p)); const unique = new Set(results); assertEquals( unique.size, paths.length, "all special-char paths must produce unique encodings" ); console.log( " PASS: encodePath produces unique output for different special chars" ); passed++; } // Test 5: output is always alphanumeric (safe for Traefik names) { const paths = [ "/", "/api", "/a/b", "/a-b", "/a.b", "/complex/path/here" ]; for (const p of paths) { const e = encodePath(p); assertEquals( /^[a-zA-Z0-9]+$/.test(e), true, `encodePath("${p}") = "${e}" must be alphanumeric` ); } console.log(" PASS: encodePath output is always alphanumeric"); passed++; } // Test 6: deterministic { assertEquals( encodePath("/api"), encodePath("/api"), "same input same output" ); assertEquals( encodePath("/a/b/c"), encodePath("/a/b/c"), "same input same output" ); console.log(" PASS: encodePath is deterministic"); passed++; } // Test 7: many distinct paths never collide { const paths = [ "/", "/api", "/api/v1", "/api/v2", "/a/b", "/a-b", "/a.b", "/a_b", "/health", "/health/check", "/admin", "/admin/users", "/api/v1/users", "/api/v1/posts", "/app", "/app/dashboard" ]; const encoded = new Set(paths.map((p) => encodePath(p))); assertEquals( encoded.size, paths.length, `expected ${paths.length} unique encodings, got ${encoded.size}` ); console.log(" PASS: 16 realistic paths all produce unique encodings"); passed++; } // ── Collision fix: the actual bug we're fixing ─────────────────── // Test 8: /a/b and /a-b now have different keys (THE BUG FIX) { const keyAB = newKeyComputation(1, "/a/b", "prefix", null, null); const keyDash = newKeyComputation(1, "/a-b", "prefix", null, null); assertEquals( keyAB !== keyDash, true, "/a/b and /a-b MUST have different keys" ); console.log(" PASS: collision fix — /a/b vs /a-b have different keys"); passed++; } // Test 9: demonstrate the old bug — old code maps /a/b and /a-b to same key { const oldKeyAB = oldKeyComputation(1, "/a/b", "prefix", null, null); const oldKeyDash = oldKeyComputation(1, "/a-b", "prefix", null, null); assertEquals( oldKeyAB, oldKeyDash, "old code MUST have this collision (confirms the bug exists)" ); console.log(" PASS: confirmed old code bug — /a/b and /a-b collided"); passed++; } // Test 10: /api/v1 and /api-v1 — old code collision, new code fixes it { const oldKey1 = oldKeyComputation(1, "/api/v1", "prefix", null, null); const oldKey2 = oldKeyComputation(1, "/api-v1", "prefix", null, null); assertEquals( oldKey1, oldKey2, "old code collision for /api/v1 vs /api-v1" ); const newKey1 = newKeyComputation(1, "/api/v1", "prefix", null, null); const newKey2 = newKeyComputation(1, "/api-v1", "prefix", null, null); assertEquals( newKey1 !== newKey2, true, "new code must separate /api/v1 and /api-v1" ); console.log(" PASS: collision fix — /api/v1 vs /api-v1"); passed++; } // Test 11: /app.v2 and /app/v2 and /app-v2 — three-way collision fixed { const a = newKeyComputation(1, "/app.v2", "prefix", null, null); const b = newKeyComputation(1, "/app/v2", "prefix", null, null); const c = newKeyComputation(1, "/app-v2", "prefix", null, null); const keys = new Set([a, b, c]); assertEquals( keys.size, 3, "three paths must produce three unique keys" ); console.log( " PASS: collision fix — three-way /app.v2, /app/v2, /app-v2" ); passed++; } // ── Edge cases ─────────────────────────────────────────────────── // Test 12: same path in different resources — always separate { const key1 = newKeyComputation(1, "/api", "prefix", null, null); const key2 = newKeyComputation(2, "/api", "prefix", null, null); assertEquals( key1 !== key2, true, "different resources with same path must have different keys" ); console.log(" PASS: edge case — same path, different resources"); passed++; } // Test 13: same resource, different pathMatchType — separate keys { const exact = newKeyComputation(1, "/api", "exact", null, null); const prefix = newKeyComputation(1, "/api", "prefix", null, null); assertEquals( exact !== prefix, true, "exact vs prefix must have different keys" ); console.log(" PASS: edge case — same path, different match types"); passed++; } // Test 14: same resource and path, different rewrite config — separate keys { const noRewrite = newKeyComputation(1, "/api", "prefix", null, null); const withRewrite = newKeyComputation( 1, "/api", "prefix", "/backend", "prefix" ); assertEquals( noRewrite !== withRewrite, true, "with vs without rewrite must have different keys" ); console.log(" PASS: edge case — same path, different rewrite config"); passed++; } // Test 15: paths with special URL characters { const paths = ["/api?foo", "/api#bar", "/api%20baz", "/api+qux"]; const keys = new Set( paths.map((p) => newKeyComputation(1, p, "prefix", null, null)) ); assertEquals( keys.size, paths.length, "special URL chars must produce unique keys" ); console.log(" PASS: edge case — special URL characters in paths"); passed++; } console.log(`\nAll ${passed} tests passed!`); } try { runTests(); } catch (error) { console.error("Test failed:", error); process.exit(1); } ================================================ FILE: server/lib/traefik/traefikConfig.test.ts ================================================ import { assertEquals } from "@test/assert"; import { isDomainCoveredByWildcard } from "./TraefikConfigManager"; function runTests() { console.log("Running wildcard domain coverage tests..."); // Test case 1: Basic wildcard certificate at example.com const basicWildcardCerts = new Map([ ["example.com", { exists: true, wildcard: true }] ]); // Should match first-level subdomains assertEquals( isDomainCoveredByWildcard("level1.example.com", basicWildcardCerts), true, "Wildcard cert at example.com should match level1.example.com" ); assertEquals( isDomainCoveredByWildcard("api.example.com", basicWildcardCerts), true, "Wildcard cert at example.com should match api.example.com" ); assertEquals( isDomainCoveredByWildcard("www.example.com", basicWildcardCerts), true, "Wildcard cert at example.com should match www.example.com" ); // Should match the root domain (exact match) assertEquals( isDomainCoveredByWildcard("example.com", basicWildcardCerts), true, "Wildcard cert at example.com should match example.com itself" ); // Should NOT match second-level subdomains assertEquals( isDomainCoveredByWildcard( "level2.level1.example.com", basicWildcardCerts ), false, "Wildcard cert at example.com should NOT match level2.level1.example.com" ); assertEquals( isDomainCoveredByWildcard( "deep.nested.subdomain.example.com", basicWildcardCerts ), false, "Wildcard cert at example.com should NOT match deep.nested.subdomain.example.com" ); // Should NOT match different domains assertEquals( isDomainCoveredByWildcard("test.otherdomain.com", basicWildcardCerts), false, "Wildcard cert at example.com should NOT match test.otherdomain.com" ); assertEquals( isDomainCoveredByWildcard("notexample.com", basicWildcardCerts), false, "Wildcard cert at example.com should NOT match notexample.com" ); // Test case 2: Multiple wildcard certificates const multipleWildcardCerts = new Map([ ["example.com", { exists: true, wildcard: true }], ["test.org", { exists: true, wildcard: true }], ["api.service.net", { exists: true, wildcard: true }] ]); assertEquals( isDomainCoveredByWildcard("app.example.com", multipleWildcardCerts), true, "Should match subdomain of first wildcard cert" ); assertEquals( isDomainCoveredByWildcard("staging.test.org", multipleWildcardCerts), true, "Should match subdomain of second wildcard cert" ); assertEquals( isDomainCoveredByWildcard("v1.api.service.net", multipleWildcardCerts), true, "Should match subdomain of third wildcard cert" ); assertEquals( isDomainCoveredByWildcard( "deep.nested.api.service.net", multipleWildcardCerts ), false, "Should NOT match multi-level subdomain of third wildcard cert" ); // Test exact domain matches for multiple certs assertEquals( isDomainCoveredByWildcard("example.com", multipleWildcardCerts), true, "Should match exact domain of first wildcard cert" ); assertEquals( isDomainCoveredByWildcard("test.org", multipleWildcardCerts), true, "Should match exact domain of second wildcard cert" ); assertEquals( isDomainCoveredByWildcard("api.service.net", multipleWildcardCerts), true, "Should match exact domain of third wildcard cert" ); // Test case 3: Non-wildcard certificates (should not match anything) const nonWildcardCerts = new Map([ ["example.com", { exists: true, wildcard: false }], ["specific.domain.com", { exists: true, wildcard: false }] ]); assertEquals( isDomainCoveredByWildcard("sub.example.com", nonWildcardCerts), false, "Non-wildcard cert should not match subdomains" ); assertEquals( isDomainCoveredByWildcard("example.com", nonWildcardCerts), false, "Non-wildcard cert should not match even exact domain via this function" ); // Test case 4: Non-existent certificates (should not match) const nonExistentCerts = new Map([ ["example.com", { exists: false, wildcard: true }], ["missing.com", { exists: false, wildcard: true }] ]); assertEquals( isDomainCoveredByWildcard("sub.example.com", nonExistentCerts), false, "Non-existent wildcard cert should not match" ); // Test case 5: Edge cases with special domain names const specialDomainCerts = new Map([ ["localhost", { exists: true, wildcard: true }], ["127-0-0-1.nip.io", { exists: true, wildcard: true }], ["xn--e1afmkfd.xn--p1ai", { exists: true, wildcard: true }] // IDN domain ]); assertEquals( isDomainCoveredByWildcard("app.localhost", specialDomainCerts), true, "Should match subdomain of localhost wildcard" ); assertEquals( isDomainCoveredByWildcard("test.127-0-0-1.nip.io", specialDomainCerts), true, "Should match subdomain of nip.io wildcard" ); assertEquals( isDomainCoveredByWildcard( "sub.xn--e1afmkfd.xn--p1ai", specialDomainCerts ), true, "Should match subdomain of IDN wildcard" ); // Test case 6: Empty input and edge cases const emptyCerts = new Map(); assertEquals( isDomainCoveredByWildcard("any.domain.com", emptyCerts), false, "Empty certificate map should not match any domain" ); // Test case 7: Domains with single character components const singleCharCerts = new Map([ ["a.com", { exists: true, wildcard: true }], ["x.y.z", { exists: true, wildcard: true }] ]); assertEquals( isDomainCoveredByWildcard("b.a.com", singleCharCerts), true, "Should match single character subdomain" ); assertEquals( isDomainCoveredByWildcard("w.x.y.z", singleCharCerts), true, "Should match single character subdomain of multi-part domain" ); assertEquals( isDomainCoveredByWildcard("v.w.x.y.z", singleCharCerts), false, "Should NOT match multi-level subdomain of single char domain" ); // Test case 8: Domains with numbers and hyphens const numericCerts = new Map([ ["api-v2.service-1.com", { exists: true, wildcard: true }], ["123.456.net", { exists: true, wildcard: true }] ]); assertEquals( isDomainCoveredByWildcard("staging.api-v2.service-1.com", numericCerts), true, "Should match subdomain with hyphens and numbers" ); assertEquals( isDomainCoveredByWildcard("test.123.456.net", numericCerts), true, "Should match subdomain with numeric components" ); assertEquals( isDomainCoveredByWildcard( "deep.staging.api-v2.service-1.com", numericCerts ), false, "Should NOT match multi-level subdomain with hyphens and numbers" ); console.log("All wildcard domain coverage tests passed!"); } // Run all tests try { runTests(); } catch (error) { console.error("Test failed:", error); process.exit(1); } ================================================ FILE: server/lib/traefik/utils.ts ================================================ import logger from "@server/logger"; export function sanitize(input: string | null | undefined): string | undefined { if (!input) return undefined; // clean any non alphanumeric characters from the input and replace with dashes // the input cant be too long either, so limit to 50 characters if (input.length > 50) { input = input.substring(0, 50); } return input .replace(/[^a-zA-Z0-9-]/g, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } /** * Encode a URL path into a collision-free alphanumeric string suitable for use * in Traefik map keys. * * Unlike sanitize(), this preserves uniqueness by encoding each non-alphanumeric * character as its hex code. Different paths always produce different outputs. * * encodePath("/api") => "2fapi" * encodePath("/a/b") => "2fa2fb" * encodePath("/a-b") => "2fa2db" (different from /a/b) * encodePath("/") => "2f" * encodePath(null) => "" */ export function encodePath(path: string | null | undefined): string { if (!path) return ""; return path.replace(/[^a-zA-Z0-9]/g, (ch) => { return ch.charCodeAt(0).toString(16); }); } export function validatePathRewriteConfig( path: string | null, pathMatchType: string | null, rewritePath: string | null, rewritePathType: string | null ): { isValid: boolean; error?: string } { // If no path matching is configured, no rewriting is possible if (!path || !pathMatchType) { if (rewritePath || rewritePathType) { return { isValid: false, error: "Path rewriting requires path matching to be configured" }; } return { isValid: true }; } if (rewritePathType !== "stripPrefix") { if ( (rewritePath && !rewritePathType) || (!rewritePath && rewritePathType) ) { return { isValid: false, error: "Both rewritePath and rewritePathType must be specified together" }; } } if (!rewritePath || !rewritePathType) { return { isValid: true }; } const validPathMatchTypes = ["exact", "prefix", "regex"]; if (!validPathMatchTypes.includes(pathMatchType)) { return { isValid: false, error: `Invalid pathMatchType: ${pathMatchType}. Must be one of: ${validPathMatchTypes.join(", ")}` }; } const validRewritePathTypes = ["exact", "prefix", "regex", "stripPrefix"]; if (!validRewritePathTypes.includes(rewritePathType)) { return { isValid: false, error: `Invalid rewritePathType: ${rewritePathType}. Must be one of: ${validRewritePathTypes.join(", ")}` }; } if (pathMatchType === "regex") { try { new RegExp(path); } catch (e) { return { isValid: false, error: `Invalid regex pattern in path: ${path}` }; } } // Additional validation for stripPrefix if (rewritePathType === "stripPrefix") { if (pathMatchType !== "prefix") { logger.warn( `stripPrefix rewrite type is most effective with prefix path matching. Current match type: ${pathMatchType}` ); } } return { isValid: true }; } ================================================ FILE: server/lib/userOrg.ts ================================================ 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); } } } ================================================ FILE: server/lib/validators.test.ts ================================================ import { isValidUrlGlobPattern } from "./validators"; import { assertEquals } from "@test/assert"; function runTests() { console.log("Running URL pattern validation tests..."); // Test valid patterns assertEquals( isValidUrlGlobPattern("simple"), true, "Simple path segment should be valid" ); assertEquals( isValidUrlGlobPattern("simple/path"), true, "Simple path with slash should be valid" ); assertEquals( isValidUrlGlobPattern("/leading/slash"), true, "Path with leading slash should be valid" ); assertEquals( isValidUrlGlobPattern("path/"), true, "Path with trailing slash should be valid" ); assertEquals( isValidUrlGlobPattern("path/*"), true, "Path with wildcard segment should be valid" ); assertEquals( isValidUrlGlobPattern("*"), true, "Single wildcard should be valid" ); assertEquals( isValidUrlGlobPattern("*/subpath"), true, "Wildcard with subpath should be valid" ); assertEquals( isValidUrlGlobPattern("path/*/more"), true, "Path with wildcard in the middle should be valid" ); // Test with special characters assertEquals( isValidUrlGlobPattern("path-with-dash"), true, "Path with dash should be valid" ); assertEquals( isValidUrlGlobPattern("path_with_underscore"), true, "Path with underscore should be valid" ); assertEquals( isValidUrlGlobPattern("path.with.dots"), true, "Path with dots should be valid" ); assertEquals( isValidUrlGlobPattern("path~with~tilde"), true, "Path with tilde should be valid" ); assertEquals( isValidUrlGlobPattern("path!with!exclamation"), true, "Path with exclamation should be valid" ); assertEquals( isValidUrlGlobPattern("path$with$dollar"), true, "Path with dollar should be valid" ); assertEquals( isValidUrlGlobPattern("path&with&ersand"), true, "Path with ampersand should be valid" ); assertEquals( isValidUrlGlobPattern("path'with'quote"), true, "Path with quote should be valid" ); assertEquals( isValidUrlGlobPattern("path(with)parentheses"), true, "Path with parentheses should be valid" ); assertEquals( isValidUrlGlobPattern("path+with+plus"), true, "Path with plus should be valid" ); assertEquals( isValidUrlGlobPattern("path,with,comma"), true, "Path with comma should be valid" ); assertEquals( isValidUrlGlobPattern("path;with;semicolon"), true, "Path with semicolon should be valid" ); assertEquals( isValidUrlGlobPattern("path=with=equals"), true, "Path with equals should be valid" ); assertEquals( isValidUrlGlobPattern("path:with:colon"), true, "Path with colon should be valid" ); assertEquals( isValidUrlGlobPattern("path@with@at"), true, "Path with at should be valid" ); // Test with percent encoding assertEquals( isValidUrlGlobPattern("path%20with%20spaces"), true, "Path with percent-encoded spaces should be valid" ); assertEquals( isValidUrlGlobPattern("path%2Fwith%2Fencoded%2Fslashes"), true, "Path with percent-encoded slashes should be valid" ); // Test with wildcards in segments (the fixed functionality) assertEquals( isValidUrlGlobPattern("padbootstrap*"), true, "Path with wildcard at the end of segment should be valid" ); assertEquals( isValidUrlGlobPattern("pad*bootstrap"), true, "Path with wildcard in the middle of segment should be valid" ); assertEquals( isValidUrlGlobPattern("*bootstrap"), true, "Path with wildcard at the start of segment should be valid" ); assertEquals( isValidUrlGlobPattern("multiple*wildcards*in*segment"), true, "Path with multiple wildcards in segment should be valid" ); assertEquals( isValidUrlGlobPattern("wild*/cards/in*/different/seg*ments"), true, "Path with wildcards in different segments should be valid" ); // Test invalid patterns assertEquals( isValidUrlGlobPattern(""), false, "Empty string should be invalid" ); assertEquals( isValidUrlGlobPattern("//double/slash"), false, "Path with double slash should be invalid" ); assertEquals( isValidUrlGlobPattern("path//end"), false, "Path with double slash in the middle should be invalid" ); assertEquals( isValidUrlGlobPattern("invalid"), false, "Path with invalid characters should be invalid" ); assertEquals( isValidUrlGlobPattern("invalid|char"), false, "Path with invalid pipe character should be invalid" ); assertEquals( isValidUrlGlobPattern('invalid"char'), false, "Path with invalid quote character should be invalid" ); assertEquals( isValidUrlGlobPattern("invalid`char"), false, "Path with invalid backtick character should be invalid" ); assertEquals( isValidUrlGlobPattern("invalid^char"), false, "Path with invalid caret character should be invalid" ); assertEquals( isValidUrlGlobPattern("invalid\\char"), false, "Path with invalid backslash character should be invalid" ); assertEquals( isValidUrlGlobPattern("invalid[char]"), false, "Path with invalid square brackets should be invalid" ); assertEquals( isValidUrlGlobPattern("invalid{char}"), false, "Path with invalid curly braces should be invalid" ); // Test invalid percent encoding assertEquals( isValidUrlGlobPattern("invalid%2"), false, "Path with incomplete percent encoding should be invalid" ); assertEquals( isValidUrlGlobPattern("invalid%GZ"), false, "Path with invalid hex in percent encoding should be invalid" ); assertEquals( isValidUrlGlobPattern("invalid%"), false, "Path with isolated percent sign should be invalid" ); console.log("All tests passed!"); } // Run all tests try { runTests(); } catch (error) { console.error("Test failed:", error); } ================================================ FILE: server/lib/validators.ts ================================================ import z from "zod"; import ipaddr from "ipaddr.js"; export function isValidCIDR(cidr: string): boolean { return ( z.cidrv4().safeParse(cidr).success || z.cidrv6().safeParse(cidr).success ); } export function isValidIP(ip: string): boolean { return z.ipv4().safeParse(ip).success || z.ipv6().safeParse(ip).success; } export function isValidUrlGlobPattern(pattern: string): boolean { if (pattern === "/") { return true; } // Remove leading slash if present pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; // Empty string is not valid if (!pattern) { return false; } // Split path into segments const segments = pattern.split("/"); // Check each segment for (let i = 0; i < segments.length; i++) { const segment = segments[i]; // Empty segments are not allowed (double slashes), except at the end if (!segment && i !== segments.length - 1) { return false; } // Check each character in the segment for (let j = 0; j < segment.length; j++) { const char = segment[j]; // Check for percent-encoded sequences if (char === "%" && j + 2 < segment.length) { const hex1 = segment[j + 1]; const hex2 = segment[j + 2]; if ( !/^[0-9A-Fa-f]$/.test(hex1) || !/^[0-9A-Fa-f]$/.test(hex2) ) { return false; } j += 2; // Skip the next two characters continue; } // Allow: // - unreserved (A-Z a-z 0-9 - . _ ~) // - sub-delims (! $ & ' ( ) * + , ; =) // - @ : for compatibility with some systems if (!/^[A-Za-z0-9\-._~!$&'()*+,;#=@:]$/.test(char)) { return false; } } } return true; } export function isUrlValid(url: string | undefined) { if (!url) return true; // the link is optional in the schema so if it's empty it's valid var pattern = new RegExp( "^(https?:\\/\\/)?" + // protocol "((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name "((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address "(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path "(\\?[;&a-z\\d%_.~+=-]*)?" + // query string "(\\#[-a-z\\d_]*)?$", "i" ); return !!pattern.test(url); } export function isTargetValid(value: string | undefined) { if (!value) return true; const DOMAIN_REGEX = /^[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?)*$/; // const IPV4_REGEX = // /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; // const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i; try { const addr = ipaddr.parse(value); return addr.kind() === "ipv4" || addr.kind() === "ipv6"; } catch { // fall through to domain regex check } return DOMAIN_REGEX.test(value); } export function isValidDomain(domain: string): boolean { // Check overall length if (domain.length > 253) return false; // Check for invalid characters or patterns if ( domain.startsWith(".") || domain.endsWith(".") || domain.includes("..") ) { return false; } const labels = domain.split("."); // Must have at least 2 labels (domain + TLD) if (labels.length < 2) return false; // Validate each label for (const label of labels) { if (label.length === 0 || label.length > 63) return false; if (label.startsWith("-") || label.endsWith("-")) return false; if (!/^[a-zA-Z0-9-]+$/.test(label)) return false; } // TLD should be at least 2 characters and contain only letters const tld = labels[labels.length - 1]; if (tld.length < 2 || !/^[a-zA-Z]+$/.test(tld)) return false; // Check if TLD is in the list of valid TLDs if (!validTlds.includes(tld.toUpperCase())) return false; return true; } export function validateHeaders(headers: string): boolean { // Validate comma-separated headers in format "Header-Name: value" const headerPairs = headers.split(",").map((pair) => pair.trim()); return headerPairs.every((pair) => { // Check if the pair contains exactly one colon const colonCount = (pair.match(/:/g) || []).length; if (colonCount !== 1) { return false; } const colonIndex = pair.indexOf(":"); if (colonIndex === 0 || colonIndex === pair.length - 1) { return false; } const headerName = pair.substring(0, colonIndex).trim(); const headerValue = pair.substring(colonIndex + 1).trim(); // Header name should not be empty and should contain valid characters // Header names are case-insensitive and can contain alphanumeric, hyphens const headerNameRegex = /^[a-zA-Z0-9\-_]+$/; if (!headerName || !headerNameRegex.test(headerName)) { return false; } // Header value should not be empty and should not contain colons if (!headerValue || headerValue.includes(":")) { return false; } return true; }); } export function isSecondLevelDomain(domain: string): boolean { if (!domain || typeof domain !== "string") { return false; } const trimmedDomain = domain.trim().toLowerCase(); // Split into parts const parts = trimmedDomain.split("."); // Should have exactly 2 parts for a second-level domain (e.g., "example.com") if (parts.length !== 2) { return false; } // Check if the TLD part is valid const tld = parts[1].toUpperCase(); return validTlds.includes(tld); } const validTlds = [ "AAA", "AARP", "ABB", "ABBOTT", "ABBVIE", "ABC", "ABLE", "ABOGADO", "ABUDHABI", "AC", "ACADEMY", "ACCENTURE", "ACCOUNTANT", "ACCOUNTANTS", "ACO", "ACTOR", "AD", "ADS", "ADULT", "AE", "AEG", "AERO", "AETNA", "AF", "AFL", "AFRICA", "AG", "AGAKHAN", "AGENCY", "AI", "AIG", "AIRBUS", "AIRFORCE", "AIRTEL", "AKDN", "AL", "ALIBABA", "ALIPAY", "ALLFINANZ", "ALLSTATE", "ALLY", "ALSACE", "ALSTOM", "AM", "AMAZON", "AMERICANEXPRESS", "AMERICANFAMILY", "AMEX", "AMFAM", "AMICA", "AMSTERDAM", "ANALYTICS", "ANDROID", "ANQUAN", "ANZ", "AO", "AOL", "APARTMENTS", "APP", "APPLE", "AQ", "AQUARELLE", "AR", "ARAB", "ARAMCO", "ARCHI", "ARMY", "ARPA", "ART", "ARTE", "AS", "ASDA", "ASIA", "ASSOCIATES", "AT", "ATHLETA", "ATTORNEY", "AU", "AUCTION", "AUDI", "AUDIBLE", "AUDIO", "AUSPOST", "AUTHOR", "AUTO", "AUTOS", "AW", "AWS", "AX", "AXA", "AZ", "AZURE", "BA", "BABY", "BAIDU", "BANAMEX", "BAND", "BANK", "BAR", "BARCELONA", "BARCLAYCARD", "BARCLAYS", "BAREFOOT", "BARGAINS", "BASEBALL", "BASKETBALL", "BAUHAUS", "BAYERN", "BB", "BBC", "BBT", "BBVA", "BCG", "BCN", "BD", "BE", "BEATS", "BEAUTY", "BEER", "BERLIN", "BEST", "BESTBUY", "BET", "BF", "BG", "BH", "BHARTI", "BI", "BIBLE", "BID", "BIKE", "BING", "BINGO", "BIO", "BIZ", "BJ", "BLACK", "BLACKFRIDAY", "BLOCKBUSTER", "BLOG", "BLOOMBERG", "BLUE", "BM", "BMS", "BMW", "BN", "BNPPARIBAS", "BO", "BOATS", "BOEHRINGER", "BOFA", "BOM", "BOND", "BOO", "BOOK", "BOOKING", "BOSCH", "BOSTIK", "BOSTON", "BOT", "BOUTIQUE", "BOX", "BR", "BRADESCO", "BRIDGESTONE", "BROADWAY", "BROKER", "BROTHER", "BRUSSELS", "BS", "BT", "BUILD", "BUILDERS", "BUSINESS", "BUY", "BUZZ", "BV", "BW", "BY", "BZ", "BZH", "CA", "CAB", "CAFE", "CAL", "CALL", "CALVINKLEIN", "CAM", "CAMERA", "CAMP", "CANON", "CAPETOWN", "CAPITAL", "CAPITALONE", "CAR", "CARAVAN", "CARDS", "CARE", "CAREER", "CAREERS", "CARS", "CASA", "CASE", "CASH", "CASINO", "CAT", "CATERING", "CATHOLIC", "CBA", "CBN", "CBRE", "CC", "CD", "CENTER", "CEO", "CERN", "CF", "CFA", "CFD", "CG", "CH", "CHANEL", "CHANNEL", "CHARITY", "CHASE", "CHAT", "CHEAP", "CHINTAI", "CHRISTMAS", "CHROME", "CHURCH", "CI", "CIPRIANI", "CIRCLE", "CISCO", "CITADEL", "CITI", "CITIC", "CITY", "CK", "CL", "CLAIMS", "CLEANING", "CLICK", "CLINIC", "CLINIQUE", "CLOTHING", "CLOUD", "CLUB", "CLUBMED", "CM", "CN", "CO", "COACH", "CODES", "COFFEE", "COLLEGE", "COLOGNE", "COM", "COMMBANK", "COMMUNITY", "COMPANY", "COMPARE", "COMPUTER", "COMSEC", "CONDOS", "CONSTRUCTION", "CONSULTING", "CONTACT", "CONTRACTORS", "COOKING", "COOL", "COOP", "CORSICA", "COUNTRY", "COUPON", "COUPONS", "COURSES", "CPA", "CR", "CREDIT", "CREDITCARD", "CREDITUNION", "CRICKET", "CROWN", "CRS", "CRUISE", "CRUISES", "CU", "CUISINELLA", "CV", "CW", "CX", "CY", "CYMRU", "CYOU", "CZ", "DAD", "DANCE", "DATA", "DATE", "DATING", "DATSUN", "DAY", "DCLK", "DDS", "DE", "DEAL", "DEALER", "DEALS", "DEGREE", "DELIVERY", "DELL", "DELOITTE", "DELTA", "DEMOCRAT", "DENTAL", "DENTIST", "DESI", "DESIGN", "DEV", "DHL", "DIAMONDS", "DIET", "DIGITAL", "DIRECT", "DIRECTORY", "DISCOUNT", "DISCOVER", "DISH", "DIY", "DJ", "DK", "DM", "DNP", "DO", "DOCS", "DOCTOR", "DOG", "DOMAINS", "DOT", "DOWNLOAD", "DRIVE", "DTV", "DUBAI", "DUNLOP", "DUPONT", "DURBAN", "DVAG", "DVR", "DZ", "EARTH", "EAT", "EC", "ECO", "EDEKA", "EDU", "EDUCATION", "EE", "EG", "EMAIL", "EMERCK", "ENERGY", "ENGINEER", "ENGINEERING", "ENTERPRISES", "EPSON", "EQUIPMENT", "ER", "ERICSSON", "ERNI", "ES", "ESQ", "ESTATE", "ET", "EU", "EUROVISION", "EUS", "EVENTS", "EXCHANGE", "EXPERT", "EXPOSED", "EXPRESS", "EXTRASPACE", "FAGE", "FAIL", "FAIRWINDS", "FAITH", "FAMILY", "FAN", "FANS", "FARM", "FARMERS", "FASHION", "FAST", "FEDEX", "FEEDBACK", "FERRARI", "FERRERO", "FI", "FIDELITY", "FIDO", "FILM", "FINAL", "FINANCE", "FINANCIAL", "FIRE", "FIRESTONE", "FIRMDALE", "FISH", "FISHING", "FIT", "FITNESS", "FJ", "FK", "FLICKR", "FLIGHTS", "FLIR", "FLORIST", "FLOWERS", "FLY", "FM", "FO", "FOO", "FOOD", "FOOTBALL", "FORD", "FOREX", "FORSALE", "FORUM", "FOUNDATION", "FOX", "FR", "FREE", "FRESENIUS", "FRL", "FROGANS", "FRONTIER", "FTR", "FUJITSU", "FUN", "FUND", "FURNITURE", "FUTBOL", "FYI", "GA", "GAL", "GALLERY", "GALLO", "GALLUP", "GAME", "GAMES", "GAP", "GARDEN", "GAY", "GB", "GBIZ", "GD", "GDN", "GE", "GEA", "GENT", "GENTING", "GEORGE", "GF", "GG", "GGEE", "GH", "GI", "GIFT", "GIFTS", "GIVES", "GIVING", "GL", "GLASS", "GLE", "GLOBAL", "GLOBO", "GM", "GMAIL", "GMBH", "GMO", "GMX", "GN", "GODADDY", "GOLD", "GOLDPOINT", "GOLF", "GOO", "GOODYEAR", "GOOG", "GOOGLE", "GOP", "GOT", "GOV", "GP", "GQ", "GR", "GRAINGER", "GRAPHICS", "GRATIS", "GREEN", "GRIPE", "GROCERY", "GROUP", "GS", "GT", "GU", "GUCCI", "GUGE", "GUIDE", "GUITARS", "GURU", "GW", "GY", "HAIR", "HAMBURG", "HANGOUT", "HAUS", "HBO", "HDFC", "HDFCBANK", "HEALTH", "HEALTHCARE", "HELP", "HELSINKI", "HERE", "HERMES", "HIPHOP", "HISAMITSU", "HITACHI", "HIV", "HK", "HKT", "HM", "HN", "HOCKEY", "HOLDINGS", "HOLIDAY", "HOMEDEPOT", "HOMEGOODS", "HOMES", "HOMESENSE", "HONDA", "HORSE", "HOSPITAL", "HOST", "HOSTING", "HOT", "HOTELS", "HOTMAIL", "HOUSE", "HOW", "HR", "HSBC", "HT", "HU", "HUGHES", "HYATT", "HYUNDAI", "IBM", "ICBC", "ICE", "ICU", "ID", "IE", "IEEE", "IFM", "IKANO", "IL", "IM", "IMAMAT", "IMDB", "IMMO", "IMMOBILIEN", "IN", "INC", "INDUSTRIES", "INFINITI", "INFO", "ING", "INK", "INSTITUTE", "INSURANCE", "INSURE", "INT", "INTERNATIONAL", "INTUIT", "INVESTMENTS", "IO", "IPIRANGA", "IQ", "IR", "IRISH", "IS", "ISMAILI", "IST", "ISTANBUL", "IT", "ITAU", "ITV", "JAGUAR", "JAVA", "JCB", "JE", "JEEP", "JETZT", "JEWELRY", "JIO", "JLL", "JM", "JMP", "JNJ", "JO", "JOBS", "JOBURG", "JOT", "JOY", "JP", "JPMORGAN", "JPRS", "JUEGOS", "JUNIPER", "KAUFEN", "KDDI", "KE", "KERRYHOTELS", "KERRYPROPERTIES", "KFH", "KG", "KH", "KI", "KIA", "KIDS", "KIM", "KINDLE", "KITCHEN", "KIWI", "KM", "KN", "KOELN", "KOMATSU", "KOSHER", "KP", "KPMG", "KPN", "KR", "KRD", "KRED", "KUOKGROUP", "KW", "KY", "KYOTO", "KZ", "LA", "LACAIXA", "LAMBORGHINI", "LAMER", "LAND", "LANDROVER", "LANXESS", "LASALLE", "LAT", "LATINO", "LATROBE", "LAW", "LAWYER", "LB", "LC", "LDS", "LEASE", "LECLERC", "LEFRAK", "LEGAL", "LEGO", "LEXUS", "LGBT", "LI", "LIDL", "LIFE", "LIFEINSURANCE", "LIFESTYLE", "LIGHTING", "LIKE", "LILLY", "LIMITED", "LIMO", "LINCOLN", "LINK", "LIVE", "LIVING", "LK", "LLC", "LLP", "LOAN", "LOANS", "LOCKER", "LOCUS", "LOL", "LONDON", "LOTTE", "LOTTO", "LOVE", "LPL", "LPLFINANCIAL", "LR", "LS", "LT", "LTD", "LTDA", "LU", "LUNDBECK", "LUXE", "LUXURY", "LV", "LY", "MA", "MADRID", "MAIF", "MAISON", "MAKEUP", "MAN", "MANAGEMENT", "MANGO", "MAP", "MARKET", "MARKETING", "MARKETS", "MARRIOTT", "MARSHALLS", "MATTEL", "MBA", "MC", "MCKINSEY", "MD", "ME", "MED", "MEDIA", "MEET", "MELBOURNE", "MEME", "MEMORIAL", "MEN", "MENU", "MERCKMSD", "MG", "MH", "MIAMI", "MICROSOFT", "MIL", "MINI", "MINT", "MIT", "MITSUBISHI", "MK", "ML", "MLB", "MLS", "MM", "MMA", "MN", "MO", "MOBI", "MOBILE", "MODA", "MOE", "MOI", "MOM", "MONASH", "MONEY", "MONSTER", "MORMON", "MORTGAGE", "MOSCOW", "MOTO", "MOTORCYCLES", "MOV", "MOVIE", "MP", "MQ", "MR", "MS", "MSD", "MT", "MTN", "MTR", "MU", "MUSEUM", "MUSIC", "MV", "MW", "MX", "MY", "MZ", "NA", "NAB", "NAGOYA", "NAME", "NAVY", "NBA", "NC", "NE", "NEC", "NET", "NETBANK", "NETFLIX", "NETWORK", "NEUSTAR", "NEW", "NEWS", "NEXT", "NEXTDIRECT", "NEXUS", "NF", "NFL", "NG", "NGO", "NHK", "NI", "NICO", "NIKE", "NIKON", "NINJA", "NISSAN", "NISSAY", "NL", "NO", "NOKIA", "NORTON", "NOW", "NOWRUZ", "NOWTV", "NP", "NR", "NRA", "NRW", "NTT", "NU", "NYC", "NZ", "OBI", "OBSERVER", "OFFICE", "OKINAWA", "OLAYAN", "OLAYANGROUP", "OLLO", "OM", "OMEGA", "ONE", "ONG", "ONL", "ONLINE", "OOO", "OPEN", "ORACLE", "ORANGE", "ORG", "ORGANIC", "ORIGINS", "OSAKA", "OTSUKA", "OTT", "OVH", "PA", "PAGE", "PANASONIC", "PARIS", "PARS", "PARTNERS", "PARTS", "PARTY", "PAY", "PCCW", "PE", "PET", "PF", "PFIZER", "PG", "PH", "PHARMACY", "PHD", "PHILIPS", "PHONE", "PHOTO", "PHOTOGRAPHY", "PHOTOS", "PHYSIO", "PICS", "PICTET", "PICTURES", "PID", "PIN", "PING", "PINK", "PIONEER", "PIZZA", "PK", "PL", "PLACE", "PLAY", "PLAYSTATION", "PLUMBING", "PLUS", "PM", "PN", "PNC", "POHL", "POKER", "POLITIE", "PORN", "POST", "PR", "PRAXI", "PRESS", "PRIME", "PRO", "PROD", "PRODUCTIONS", "PROF", "PROGRESSIVE", "PROMO", "PROPERTIES", "PROPERTY", "PROTECTION", "PRU", "PRUDENTIAL", "PS", "PT", "PUB", "PW", "PWC", "PY", "QA", "QPON", "QUEBEC", "QUEST", "RACING", "RADIO", "RE", "READ", "REALESTATE", "REALTOR", "REALTY", "RECIPES", "RED", "REDSTONE", "REDUMBRELLA", "REHAB", "REISE", "REISEN", "REIT", "RELIANCE", "REN", "RENT", "RENTALS", "REPAIR", "REPORT", "REPUBLICAN", "REST", "RESTAURANT", "REVIEW", "REVIEWS", "REXROTH", "RICH", "RICHARDLI", "RICOH", "RIL", "RIO", "RIP", "RO", "ROCKS", "RODEO", "ROGERS", "ROOM", "RS", "RSVP", "RU", "RUGBY", "RUHR", "RUN", "RW", "RWE", "RYUKYU", "SA", "SAARLAND", "SAFE", "SAFETY", "SAKURA", "SALE", "SALON", "SAMSCLUB", "SAMSUNG", "SANDVIK", "SANDVIKCOROMANT", "SANOFI", "SAP", "SARL", "SAS", "SAVE", "SAXO", "SB", "SBI", "SBS", "SC", "SCB", "SCHAEFFLER", "SCHMIDT", "SCHOLARSHIPS", "SCHOOL", "SCHULE", "SCHWARZ", "SCIENCE", "SCOT", "SD", "SE", "SEARCH", "SEAT", "SECURE", "SECURITY", "SEEK", "SELECT", "SENER", "SERVICES", "SEVEN", "SEW", "SEX", "SEXY", "SFR", "SG", "SH", "SHANGRILA", "SHARP", "SHELL", "SHIA", "SHIKSHA", "SHOES", "SHOP", "SHOPPING", "SHOUJI", "SHOW", "SI", "SILK", "SINA", "SINGLES", "SITE", "SJ", "SK", "SKI", "SKIN", "SKY", "SKYPE", "SL", "SLING", "SM", "SMART", "SMILE", "SN", "SNCF", "SO", "SOCCER", "SOCIAL", "SOFTBANK", "SOFTWARE", "SOHU", "SOLAR", "SOLUTIONS", "SONG", "SONY", "SOY", "SPA", "SPACE", "SPORT", "SPOT", "SR", "SRL", "SS", "ST", "STADA", "STAPLES", "STAR", "STATEBANK", "STATEFARM", "STC", "STCGROUP", "STOCKHOLM", "STORAGE", "STORE", "STREAM", "STUDIO", "STUDY", "STYLE", "SU", "SUCKS", "SUPPLIES", "SUPPLY", "SUPPORT", "SURF", "SURGERY", "SUZUKI", "SV", "SWATCH", "SWISS", "SX", "SY", "SYDNEY", "SYSTEMS", "SZ", "TAB", "TAIPEI", "TALK", "TAOBAO", "TARGET", "TATAMOTORS", "TATAR", "TATTOO", "TAX", "TAXI", "TC", "TCI", "TD", "TDK", "TEAM", "TECH", "TECHNOLOGY", "TEL", "TEMASEK", "TENNIS", "TEVA", "TF", "TG", "TH", "THD", "THEATER", "THEATRE", "TIAA", "TICKETS", "TIENDA", "TIPS", "TIRES", "TIROL", "TJ", "TJMAXX", "TJX", "TK", "TKMAXX", "TL", "TM", "TMALL", "TN", "TO", "TODAY", "TOKYO", "TOOLS", "TOP", "TORAY", "TOSHIBA", "TOTAL", "TOURS", "TOWN", "TOYOTA", "TOYS", "TR", "TRADE", "TRADING", "TRAINING", "TRAVEL", "TRAVELERS", "TRAVELERSINSURANCE", "TRUST", "TRV", "TT", "TUBE", "TUI", "TUNES", "TUSHU", "TV", "TVS", "TW", "TZ", "UA", "UBANK", "UBS", "UG", "UK", "UNICOM", "UNIVERSITY", "UNO", "UOL", "UPS", "US", "UY", "UZ", "VA", "VACATIONS", "VANA", "VANGUARD", "VC", "VE", "VEGAS", "VENTURES", "VERISIGN", "VERSICHERUNG", "VET", "VG", "VI", "VIAJES", "VIDEO", "VIG", "VIKING", "VILLAS", "VIN", "VIP", "VIRGIN", "VISA", "VISION", "VIVA", "VIVO", "VLAANDEREN", "VN", "VODKA", "VOLVO", "VOTE", "VOTING", "VOTO", "VOYAGE", "VU", "WALES", "WALMART", "WALTER", "WANG", "WANGGOU", "WATCH", "WATCHES", "WEATHER", "WEATHERCHANNEL", "WEBCAM", "WEBER", "WEBSITE", "WED", "WEDDING", "WEIBO", "WEIR", "WF", "WHOSWHO", "WIEN", "WIKI", "WILLIAMHILL", "WIN", "WINDOWS", "WINE", "WINNERS", "WME", "WOLTERSKLUWER", "WOODSIDE", "WORK", "WORKS", "WORLD", "WOW", "WS", "WTC", "WTF", "XBOX", "XEROX", "XIHUAN", "XIN", "XN--11B4C3D", "XN--1CK2E1B", "XN--1QQW23A", "XN--2SCRJ9C", "XN--30RR7Y", "XN--3BST00M", "XN--3DS443G", "XN--3E0B707E", "XN--3HCRJ9C", "XN--3PXU8K", "XN--42C2D9A", "XN--45BR5CYL", "XN--45BRJ9C", "XN--45Q11C", "XN--4DBRK0CE", "XN--4GBRIM", "XN--54B7FTA0CC", "XN--55QW42G", "XN--55QX5D", "XN--5SU34J936BGSG", "XN--5TZM5G", "XN--6FRZ82G", "XN--6QQ986B3XL", "XN--80ADXHKS", "XN--80AO21A", "XN--80AQECDR1A", "XN--80ASEHDB", "XN--80ASWG", "XN--8Y0A063A", "XN--90A3AC", "XN--90AE", "XN--90AIS", "XN--9DBQ2A", "XN--9ET52U", "XN--9KRT00A", "XN--B4W605FERD", "XN--BCK1B9A5DRE4C", "XN--C1AVG", "XN--C2BR7G", "XN--CCK2B3B", "XN--CCKWCXETD", "XN--CG4BKI", "XN--CLCHC0EA0B2G2A9GCD", "XN--CZR694B", "XN--CZRS0T", "XN--CZRU2D", "XN--D1ACJ3B", "XN--D1ALF", "XN--E1A4C", "XN--ECKVDTC9D", "XN--EFVY88H", "XN--FCT429K", "XN--FHBEI", "XN--FIQ228C5HS", "XN--FIQ64B", "XN--FIQS8S", "XN--FIQZ9S", "XN--FJQ720A", "XN--FLW351E", "XN--FPCRJ9C3D", "XN--FZC2C9E2C", "XN--FZYS8D69UVGM", "XN--G2XX48C", "XN--GCKR3F0F", "XN--GECRJ9C", "XN--GK3AT1E", "XN--H2BREG3EVE", "XN--H2BRJ9C", "XN--H2BRJ9C8C", "XN--HXT814E", "XN--I1B6B1A6A2E", "XN--IMR513N", "XN--IO0A7I", "XN--J1AEF", "XN--J1AMH", "XN--J6W193G", "XN--JLQ480N2RG", "XN--JVR189M", "XN--KCRX77D1X4A", "XN--KPRW13D", "XN--KPRY57D", "XN--KPUT3I", "XN--L1ACC", "XN--LGBBAT1AD8J", "XN--MGB9AWBF", "XN--MGBA3A3EJT", "XN--MGBA3A4F16A", "XN--MGBA7C0BBN0A", "XN--MGBAAM7A8H", "XN--MGBAB2BD", "XN--MGBAH1A3HJKRD", "XN--MGBAI9AZGQP6J", "XN--MGBAYH7GPA", "XN--MGBBH1A", "XN--MGBBH1A71E", "XN--MGBC0A9AZCG", "XN--MGBCA7DZDO", "XN--MGBCPQ6GPA1A", "XN--MGBERP4A5D4AR", "XN--MGBGU82A", "XN--MGBI4ECEXP", "XN--MGBPL2FH", "XN--MGBT3DHD", "XN--MGBTX2B", "XN--MGBX4CD0AB", "XN--MIX891F", "XN--MK1BU44C", "XN--MXTQ1M", "XN--NGBC5AZD", "XN--NGBE9E0A", "XN--NGBRX", "XN--NODE", "XN--NQV7F", "XN--NQV7FS00EMA", "XN--NYQY26A", "XN--O3CW4H", "XN--OGBPF8FL", "XN--OTU796D", "XN--P1ACF", "XN--P1AI", "XN--PGBS0DH", "XN--PSSY2U", "XN--Q7CE6A", "XN--Q9JYB4C", "XN--QCKA1PMC", "XN--QXA6A", "XN--QXAM", "XN--RHQV96G", "XN--ROVU88B", "XN--RVC1E0AM3E", "XN--S9BRJ9C", "XN--SES554G", "XN--T60B56A", "XN--TCKWE", "XN--TIQ49XQYJ", "XN--UNUP4Y", "XN--VERMGENSBERATER-CTB", "XN--VERMGENSBERATUNG-PWB", "XN--VHQUV", "XN--VUQ861B", "XN--W4R85EL8FHU5DNRA", "XN--W4RS40L", "XN--WGBH1C", "XN--WGBL6A", "XN--XHQ521B", "XN--XKC2AL3HYE2A", "XN--XKC2DL3A5EE0H", "XN--Y9A3AQ", "XN--YFRO4I67O", "XN--YGBI2AMMX", "XN--ZFR164B", "XXX", "XYZ", "YACHTS", "YAHOO", "YAMAXUN", "YANDEX", "YE", "YODOBASHI", "YOGA", "YOKOHAMA", "YOU", "YOUTUBE", "YT", "YUN", "ZA", "ZAPPOS", "ZARA", "ZERO", "ZIP", "ZM", "ZONE", "ZUERICH", "ZW", "" ]; ================================================ FILE: server/license/license.ts ================================================ import { db, hostMeta, HostMeta } from "@server/db"; import { setHostMeta } from "@server/lib/hostMeta"; const keyTypes = ["host"] as const; export type LicenseKeyType = (typeof keyTypes)[number]; const keyTiers = ["personal", "enterprise"] as const; export type LicenseKeyTier = (typeof keyTiers)[number]; export type LicenseStatus = { isHostLicensed: boolean; // Are there any license keys? isLicenseValid: boolean; // Is the license key valid? hostId: string; // Host ID tier?: LicenseKeyTier; maxSites?: number; usedSites?: number; maxUsers?: number; usedUsers?: number; }; export type LicenseKeyCache = { licenseKey: string; licenseKeyEncrypted: string; valid: boolean; iat?: Date; type?: LicenseKeyType; tier?: LicenseKeyTier; terminateAt?: Date; quantity?: number; quantity_2?: number; }; export class License { private serverSecret!: string; constructor(private hostMeta: HostMeta) { } public async check(): Promise { return { hostId: this.hostMeta.hostMetaId, isHostLicensed: false, isLicenseValid: false }; } public setServerSecret(secret: string) { this.serverSecret = secret; } public async isUnlocked() { return false; } } await setHostMeta(); const [info] = await db.select().from(hostMeta).limit(1); if (!info) { throw new Error("Host information not found"); } export const license = new License(info); export default license; ================================================ FILE: server/logger.ts ================================================ import "winston-daily-rotate-file"; import config from "@server/lib/config"; import * as winston from "winston"; import path from "path"; import { APP_PATH } from "./lib/consts"; import telemetryClient from "./lib/telemetry"; // helper to get ISO8601 string in the TZ from process.env.TZ // This replaces the default Z (UTC) with the local offset from process.env.TZ const isoLocal = () => { const tz = process.env.TZ || "UTC"; const d = new Date(); const s = d.toLocaleString("sv-SE", { timeZone: tz, hour12: false }); const tzOffsetMin = d.getTimezoneOffset(); const sign = tzOffsetMin <= 0 ? "+" : "-"; const pad = (n: number) => String(n).padStart(2, "0"); const hours = pad(Math.floor(Math.abs(tzOffsetMin) / 60)); const mins = pad(Math.abs(tzOffsetMin) % 60); // Replace Z in ISO string with local offset return s.replace(" ", "T") + `${sign}${hours}:${mins}`; }; const hformat = winston.format.printf( ({ level, label, message, timestamp, stack, ...metadata }) => { let msg = `${timestamp} [${level}]${label ? `[${label}]` : ""}: ${message}`; if (stack) { msg += `\nStack: ${stack}`; } if (Object.keys(metadata).length > 0) { msg += ` ${JSON.stringify(metadata)}`; } return msg; } ); const transports: any = [new winston.transports.Console({})]; if (config.getRawConfig().app.save_logs) { transports.push( new winston.transports.DailyRotateFile({ filename: path.join(APP_PATH, "logs", "pangolin-%DATE%.log"), datePattern: "YYYY-MM-DD", zippedArchive: true, maxSize: "20m", maxFiles: "7d", createSymlink: true, symlinkName: "pangolin.log", format: winston.format.combine( winston.format.timestamp({ format: isoLocal }), winston.format.splat(), hformat ) }) ); transports.push( new winston.transports.DailyRotateFile({ filename: path.join(APP_PATH, "logs", ".machinelogs-%DATE%.json"), datePattern: "YYYY-MM-DD", zippedArchive: true, maxSize: "20m", maxFiles: "1d", createSymlink: true, symlinkName: ".machinelogs.json", format: winston.format.combine( winston.format.timestamp({ format: isoLocal }), winston.format.splat(), winston.format.json() ) }) ); } const logger = winston.createLogger({ level: config.getRawConfig().app.log_level.toLowerCase(), format: winston.format.combine( winston.format.errors({ stack: true }), winston.format.colorize(), winston.format.splat(), // Use isoLocal so timestamps respect TZ env, not just UTC winston.format.timestamp({ format: isoLocal }), hformat ), transports }); process.on("uncaughtException", (error) => { logger.error("Uncaught Exception:", { error, stack: error.stack }); process.exit(1); }); process.on("unhandledRejection", (reason, _) => { logger.error("Unhandled Rejection:", { reason }); }); export default logger; ================================================ FILE: server/middlewares/csrfProtection.ts ================================================ import { NextFunction, Request, Response } from "express"; export function csrfProtectionMiddleware( req: Request, res: Response, next: NextFunction ) { const csrfToken = req.headers["x-csrf-token"]; // Skip CSRF check for GET requests as they should be idempotent if (req.method === "GET") { next(); return; } if (!csrfToken || csrfToken !== "x-csrf-protection") { res.status(403).json({ error: "CSRF token missing or invalid" }); return; } next(); } ================================================ FILE: server/middlewares/formatError.ts ================================================ import { ErrorRequestHandler, NextFunction, Response } from "express"; import ErrorResponse from "@server/types/ErrorResponse"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import config from "@server/lib/config"; export const errorHandlerMiddleware: ErrorRequestHandler = ( error, req, res: Response, next: NextFunction ) => { const statusCode = error.statusCode || HttpCode.INTERNAL_SERVER_ERROR; // if (process.env.ENVIRONMENT !== "prod") { // logger.error(error); // } res?.status(statusCode).send({ data: null, success: false, error: true, message: error.message || "Internal Server Error", status: statusCode, stack: process.env.ENVIRONMENT === "prod" ? null : error.stack }); }; ================================================ FILE: server/middlewares/getUserOrgs.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { userOrgs, orgs } from "@server/db"; import { eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function getUserOrgs( req: Request, res: Response, next: NextFunction ) { const userId = req.user?.userId; // Assuming you have user information in the request if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } try { const userOrganizations = await db .select({ orgId: userOrgs.orgId, roleId: userOrgs.roleId }) .from(userOrgs) .where(eq(userOrgs.userId, userId)); req.userOrgIds = userOrganizations.map((org) => org.orgId); // req.userOrgRoleIds = userOrganizations.reduce((acc, org) => { // acc[org.orgId] = org.role; // return acc; // }, {} as Record); next(); } catch (error) { next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error retrieving user organizations" ) ); } } ================================================ FILE: server/middlewares/index.ts ================================================ export * from "./notFound"; export * from "./formatError"; export * from "./verifySession"; export * from "./verifyUser"; export * from "./verifyOrgAccess"; export * from "./getUserOrgs"; export * from "./verifySiteAccess"; export * from "./verifyResourceAccess"; export * from "./verifyTargetAccess"; export * from "./verifyRoleAccess"; export * from "./verifyUserAccess"; export * from "./verifyAdmin"; export * from "./verifySetResourceUsers"; export * from "./verifySetResourceClients"; export * from "./verifyUserInRole"; export * from "./verifyAccessTokenAccess"; export * from "./requestTimeout"; export * from "./verifyClientAccess"; export * from "./verifyUserHasAction"; export * from "./verifyUserIsServerAdmin"; export * from "./verifyIsLoggedInUser"; export * from "./verifyIsLoggedInUser"; export * from "./verifyClientAccess"; export * from "./integration"; export * from "./verifyUserHasAction"; export * from "./verifyApiKeyAccess"; export * from "./verifyDomainAccess"; export * from "./verifyUserIsOrgOwner"; export * from "./verifySiteResourceAccess"; export * from "./logActionAudit"; export * from "./verifyOlmAccess"; export * from "./verifyLimits"; ================================================ FILE: server/middlewares/integration/index.ts ================================================ export * from "./verifyApiKey"; export * from "./verifyApiKeyOrgAccess"; export * from "./verifyApiKeyHasAction"; export * from "./verifyApiKeySiteAccess"; export * from "./verifyApiKeyResourceAccess"; export * from "./verifyApiKeyTargetAccess"; export * from "./verifyApiKeyRoleAccess"; export * from "./verifyApiKeyUserAccess"; export * from "./verifyApiKeySetResourceUsers"; export * from "./verifyApiKeySetResourceClients"; export * from "./verifyAccessTokenAccess"; export * from "./verifyApiKeyIsRoot"; export * from "./verifyApiKeyApiKeyAccess"; export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeySiteResourceAccess"; export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyDomainAccess"; ================================================ FILE: server/middlewares/integration/verifyAccessTokenAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { resourceAccessToken, resources, apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeyAccessTokenAccess( req: Request, res: Response, next: NextFunction ) { try { const apiKey = req.apiKey; const accessTokenId = req.params.accessTokenId; if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } const [accessToken] = await db .select() .from(resourceAccessToken) .where(eq(resourceAccessToken.accessTokenId, accessTokenId)) .limit(1); if (!accessToken) { return next( createHttpError( HttpCode.NOT_FOUND, `Access token with ID ${accessTokenId} not found` ) ); } const resourceId = accessToken.resourceId; if (!resourceId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Access token with ID ${accessTokenId} does not have a resource ID` ) ); } const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } if (!resource.orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Resource with ID ${resourceId} does not have an organization ID` ) ); } // Verify that the API key is linked to the resource's organization if (!req.apiKeyOrg) { const apiKeyOrgResult = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.orgId, resource.orgId) ) ) .limit(1); if (apiKeyOrgResult.length > 0) { req.apiKeyOrg = apiKeyOrgResult[0]; } } if (!req.apiKeyOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } return next(); } catch (e) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying access token access" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKey.ts ================================================ import { verifyPassword } from "@server/auth/password"; import { db } from "@server/db"; import { apiKeys } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { eq } from "drizzle-orm"; import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; export async function verifyApiKey( req: Request, res: Response, next: NextFunction ): Promise { try { const authHeader = req.headers["authorization"]; if (!authHeader || !authHeader.startsWith("Bearer ")) { return next( createHttpError(HttpCode.UNAUTHORIZED, "API key required") ); } const key = authHeader.split(" ")[1]; // Get the token part after "Bearer" const [apiKeyId, apiKeySecret] = key.split("."); const [apiKey] = await db .select() .from(apiKeys) .where(eq(apiKeys.apiKeyId, apiKeyId)) .limit(1); if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key") ); } const secretHash = apiKey.apiKeyHash; const valid = await verifyPassword(apiKeySecret, secretHash); if (!valid) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key") ); } req.apiKey = apiKey; return next(); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred checking API key" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeyApiKeyAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { apiKeys, apiKeyOrg } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeyApiKeyAccess( req: Request, res: Response, next: NextFunction ) { try { const { apiKey: callerApiKey } = req; const apiKeyId = req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId; const orgId = req.params.orgId; if (!callerApiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (!orgId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") ); } if (!apiKeyId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") ); } if (callerApiKey.isRoot) { // Root keys can access any key in any org return next(); } const [callerApiKeyOrg] = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeys.apiKeyId, callerApiKey.apiKeyId), eq(apiKeyOrg.orgId, orgId) ) ) .limit(1); if (!callerApiKeyOrg) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `API key with ID ${apiKeyId} does not have an organization ID` ) ); } const [otherApiKeyOrg] = await db .select() .from(apiKeyOrg) .where( and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId)) ) .limit(1); if (!otherApiKeyOrg) { return next( createHttpError( HttpCode.FORBIDDEN, `API key with ID ${apiKeyId} does not have access to organization with ID ${orgId}` ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying key access" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeyClientAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { clients, db } from "@server/db"; import { apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeyClientAccess( req: Request, res: Response, next: NextFunction ) { try { const apiKey = req.apiKey; const clientId = parseInt( req.params.clientId || req.body.clientId || req.query.clientId ); if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (isNaN(clientId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid client ID") ); } if (apiKey.isRoot) { // Root keys can access any key in any org return next(); } const client = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (client.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Client with ID ${clientId} not found` ) ); } if (!client[0].orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Client with ID ${clientId} does not have an organization ID` ) ); } if (!req.apiKeyOrg) { const apiKeyOrgRes = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.orgId, client[0].orgId) ) ); req.apiKeyOrg = apiKeyOrgRes[0]; } if (!req.apiKeyOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying site access" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeyDomainAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db, domains, orgDomains, apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeyDomainAccess( req: Request, res: Response, next: NextFunction ) { try { const apiKey = req.apiKey; const domainId = req.params.domainId || req.body.domainId || req.query.domainId; const orgId = req.params.orgId; if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (!domainId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID") ); } if (apiKey.isRoot) { // Root keys can access any domain in any org return next(); } // Verify domain exists and belongs to the organization const [domain] = await db .select() .from(domains) .innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId)) .where( and( eq(orgDomains.domainId, domainId), eq(orgDomains.orgId, orgId) ) ) .limit(1); if (!domain) { return next( createHttpError( HttpCode.NOT_FOUND, `Domain with ID ${domainId} not found in organization ${orgId}` ) ); } // Verify the API key has access to this organization if (!req.apiKeyOrg) { const apiKeyOrgRes = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.orgId, orgId) ) ) .limit(1); req.apiKeyOrg = apiKeyOrgRes[0]; } if (!req.apiKeyOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying domain access" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeyHasAction.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { ActionsEnum } from "@server/auth/actions"; import { db } from "@server/db"; import { apiKeyActions } from "@server/db"; import { and, eq } from "drizzle-orm"; export function verifyApiKeyHasAction(action: ActionsEnum) { return async function ( req: Request, res: Response, next: NextFunction ): Promise { try { if (!req.apiKey) { return next( createHttpError( HttpCode.UNAUTHORIZED, "API Key not authenticated" ) ); } const [actionRes] = await db .select() .from(apiKeyActions) .where( and( eq(apiKeyActions.apiKeyId, req.apiKey.apiKeyId), eq(apiKeyActions.actionId, action) ) ); if (!actionRes) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have permission perform this action" ) ); } return next(); } catch (error) { logger.error("Error verifying key action access:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying key action access" ) ); } }; } ================================================ FILE: server/middlewares/integration/verifyApiKeyIdpAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { idp, idpOrg, apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeyIdpAccess( req: Request, res: Response, next: NextFunction ) { try { const apiKey = req.apiKey; const idpId = req.params.idpId || req.body.idpId || req.query.idpId; const orgId = req.params.orgId; if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (!orgId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") ); } if (!idpId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID") ); } if (apiKey.isRoot) { // Root keys can access any IDP in any org return next(); } const [idpRes] = await db .select() .from(idp) .innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId)) .where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId))) .limit(1); if (!idpRes || !idpRes.idp || !idpRes.idpOrg) { return next( createHttpError( HttpCode.NOT_FOUND, `IdP with ID ${idpId} not found for organization ${orgId}` ) ); } if (!req.apiKeyOrg) { const apiKeyOrgRes = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.orgId, idpRes.idpOrg.orgId) ) ); req.apiKeyOrg = apiKeyOrgRes[0]; } if (!req.apiKeyOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying IDP access" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeyIsRoot.ts ================================================ import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; export async function verifyApiKeyIsRoot( req: Request, res: Response, next: NextFunction ): Promise { try { const { apiKey } = req; if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (!apiKey.isRoot) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have root access" ) ); } return next(); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred checking API key" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeyOrgAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeyOrgAccess( req: Request, res: Response, next: NextFunction ) { try { const apiKeyId = req.apiKey?.apiKeyId; const orgId = req.params.orgId; if (!apiKeyId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (!orgId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") ); } if (req.apiKey?.isRoot) { // Root keys can access any key in any org return next(); } if (!req.apiKeyOrg) { const apiKeyOrgRes = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId) ) ); req.apiKeyOrg = apiKeyOrgRes[0]; } if (!req.apiKeyOrg) { next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } return next(); } catch (e) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying organization access" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeyResourceAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { resources, apiKeyOrg } from "@server/db"; import { eq, and } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeyResourceAccess( req: Request, res: Response, next: NextFunction ) { const apiKey = req.apiKey; const resourceId = req.params.resourceId || req.body.resourceId || req.query.resourceId; if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } try { // Retrieve the resource const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } if (apiKey.isRoot) { // Root keys can access any key in any org return next(); } if (!resource.orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Resource with ID ${resourceId} does not have an organization ID` ) ); } // Verify that the API key is linked to the resource's organization if (!req.apiKeyOrg) { const apiKeyOrgResult = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.orgId, resource.orgId) ) ) .limit(1); if (apiKeyOrgResult.length > 0) { req.apiKeyOrg = apiKeyOrgResult[0]; } } if (!req.apiKeyOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying resource access" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeyRoleAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { roles, apiKeyOrg } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; export async function verifyApiKeyRoleAccess( req: Request, res: Response, next: NextFunction ) { try { const apiKey = req.apiKey; const singleRoleId = parseInt( req.params.roleId || req.body.roleId || req.query.roleId ); if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } 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(); } const rolesData = await db .select() .from(roles) .where(inArray(roles.roleId, allRoleIds)); if (rolesData.length !== allRoleIds.length) { return next( createHttpError( HttpCode.NOT_FOUND, "One or more roles not found" ) ); } if (apiKey.isRoot) { // Root keys can access any key in any org return next(); } const orgIds = new Set(rolesData.map((role) => role.orgId)); for (const role of rolesData) { const apiKeyOrgAccess = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.orgId, role.orgId!) ) ) .limit(1); if (apiKeyOrgAccess.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, `Key does not have access to organization for role ID ${role.roleId}` ) ); } } if (orgIds.size > 1) { return next( createHttpError( HttpCode.FORBIDDEN, "Roles must belong to the same organization" ) ); } const orgId = orgIds.values().next().value; if (!orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Roles do not have an organization ID" ) ); } if (!req.apiKeyOrg) { // Retrieve the API key's organization link if not already set const apiKeyOrgRes = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.orgId, orgId) ) ) .limit(1); if (apiKeyOrgRes.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } req.apiKeyOrg = apiKeyOrgRes[0]; } return next(); } catch (error) { logger.error("Error verifying role access:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying role access" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeySetResourceClients.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { clients } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeySetResourceClients( req: Request, res: Response, next: NextFunction ) { const apiKey = req.apiKey; const singleClientId = req.params.clientId || req.body.clientId || req.query.clientId; const { clientIds } = req.body; const allClientIds = clientIds || (singleClientId ? [parseInt(singleClientId as string)] : []); if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (apiKey.isRoot) { // Root keys can access any client in any org return next(); } if (!req.apiKeyOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } if (allClientIds.length === 0) { return next(); } try { const orgId = req.apiKeyOrg.orgId; const clientsData = await db .select() .from(clients) .where( and( inArray(clients.clientId, allClientIds), eq(clients.orgId, orgId) ) ); if (clientsData.length !== allClientIds.length) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to one or more specified clients" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error checking if key has access to the specified clients" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeySetResourceUsers.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { userOrgs } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeySetResourceUsers( req: Request, res: Response, next: NextFunction ) { const apiKey = req.apiKey; const singleUserId = req.params.userId || req.body.userId || req.query.userId; const { userIds } = req.body; const allUserIds = userIds || (singleUserId ? [singleUserId] : []); if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (apiKey.isRoot) { // Root keys can access any key in any org return next(); } if (!req.apiKeyOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } if (allUserIds.length === 0) { return next(); } try { const orgId = req.apiKeyOrg.orgId; const userOrgsData = await db .select() .from(userOrgs) .where( and( inArray(userOrgs.userId, allUserIds), eq(userOrgs.orgId, orgId) ) ); if (userOrgsData.length !== allUserIds.length) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to one or more specified users" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error checking if key has access to the specified users" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeySiteAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { sites, apiKeyOrg } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeySiteAccess( req: Request, res: Response, next: NextFunction ) { try { const apiKey = req.apiKey; const siteId = parseInt( req.params.siteId || req.body.siteId || req.query.siteId ); if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (isNaN(siteId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID") ); } if (apiKey.isRoot) { // Root keys can access any key in any org return next(); } const site = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (site.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Site with ID ${siteId} not found` ) ); } if (!site[0].orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Site with ID ${siteId} does not have an organization ID` ) ); } if (!req.apiKeyOrg) { const apiKeyOrgRes = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.orgId, site[0].orgId) ) ); req.apiKeyOrg = apiKeyOrgRes[0]; } if (!req.apiKeyOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying site access" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeySiteResourceAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { siteResources, apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeySiteResourceAccess( req: Request, res: Response, next: NextFunction ) { try { const apiKey = req.apiKey; const siteResourceId = parseInt(req.params.siteResourceId); if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (!siteResourceId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Missing siteResourceId parameter" ) ); } if (apiKey.isRoot) { // Root keys can access any resource in any org return next(); } // Check if the site resource exists and belongs to the specified site and org const [siteResource] = await db .select() .from(siteResources) .where(and(eq(siteResources.siteResourceId, siteResourceId))) .limit(1); if (!siteResource) { return next( createHttpError(HttpCode.NOT_FOUND, "Site resource not found") ); } // Verify that the API key has access to the organization if (!req.apiKeyOrg) { const apiKeyOrgRes = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.orgId, siteResource.orgId) ) ) .limit(1); if (apiKeyOrgRes.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } req.apiKeyOrg = apiKeyOrgRes[0]; } // Attach the siteResource to the request for use in the next middleware/route req.siteResource = siteResource; return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying site resource access" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeyTargetAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { resources, targets, apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeyTargetAccess( req: Request, res: Response, next: NextFunction ) { try { const apiKey = req.apiKey; const targetId = parseInt(req.params.targetId); if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (isNaN(targetId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID") ); } const [target] = await db .select() .from(targets) .where(eq(targets.targetId, targetId)) .limit(1); if (!target) { return next( createHttpError( HttpCode.NOT_FOUND, `Target with ID ${targetId} not found` ) ); } const resourceId = target.resourceId; if (!resourceId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Target with ID ${targetId} does not have a resource ID` ) ); } const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } if (apiKey.isRoot) { // Root keys can access any key in any org return next(); } if (!resource.orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Resource with ID ${resourceId} does not have an organization ID` ) ); } if (!req.apiKeyOrg) { const apiKeyOrgResult = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.orgId, resource.orgId) ) ) .limit(1); if (apiKeyOrgResult.length > 0) { req.apiKeyOrg = apiKeyOrgResult[0]; } } if (!req.apiKeyOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this organization" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying target access" ) ); } } ================================================ FILE: server/middlewares/integration/verifyApiKeyUserAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyApiKeyUserAccess( req: Request, res: Response, next: NextFunction ) { try { const apiKey = req.apiKey; const reqUserId = req.params.userId || req.body.userId || req.query.userId; if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } if (!reqUserId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID") ); } if (apiKey.isRoot) { // Root keys can access any key in any org return next(); } if (!req.apiKeyOrg || !req.apiKeyOrg.orgId) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have organization access" ) ); } const orgId = req.apiKeyOrg.orgId; const [userOrgRecord] = await db .select() .from(userOrgs) .where( and(eq(userOrgs.userId, reqUserId), eq(userOrgs.orgId, orgId)) ) .limit(1); if (!userOrgRecord) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to this user" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error checking if key has access to this user" ) ); } } ================================================ FILE: server/middlewares/logActionAudit.ts ================================================ import { ActionsEnum } from "@server/auth/actions"; import { Request, Response, NextFunction } from "express"; export function logActionAudit(action: ActionsEnum) { return async function ( req: Request, res: Response, next: NextFunction ): Promise { next(); }; } export async function cleanUpOldLogs(orgId: string, retentionDays: number) { return; } ================================================ FILE: server/middlewares/logIncoming.ts ================================================ import logger from "@server/logger"; import { NextFunction, Request, Response } from "express"; export function logIncomingMiddleware( req: Request, res: Response, next: NextFunction ) { const { method, url, headers, body } = req; if (url.includes("/api/v1")) { logger.debug(`${method} ${url}`); } next(); } ================================================ FILE: server/middlewares/notFound.ts ================================================ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export function notFoundMiddleware( req: Request, res: Response, next: NextFunction ) { if (req.path.startsWith("/api")) { const message = `The requests url is not found - ${req.originalUrl}`; return next(createHttpError(HttpCode.NOT_FOUND, message)); } return next(); } export default notFoundMiddleware; ================================================ FILE: server/middlewares/requestTimeout.ts ================================================ import { Request, Response, NextFunction } from "express"; import logger from "@server/logger"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export function requestTimeoutMiddleware(timeoutMs: number = 30000) { return (req: Request, res: Response, next: NextFunction) => { // Set a timeout for the request const timeout = setTimeout(() => { if (!res.headersSent) { logger.error( `Request timeout: ${req.method} ${req.url} from ${req.ip}` ); return next( createHttpError( HttpCode.REQUEST_TIMEOUT, "Request timeout - operation took too long to complete" ) ); } }, timeoutMs); // Clear timeout when response finishes res.on("finish", () => { clearTimeout(timeout); }); // Clear timeout when response closes res.on("close", () => { clearTimeout(timeout); }); next(); }; } export default requestTimeoutMiddleware; ================================================ FILE: server/middlewares/stripDuplicateSessions.ts ================================================ import { NextFunction, Response } from "express"; import ErrorResponse from "@server/types/ErrorResponse"; import { SESSION_COOKIE_NAME, validateSessionToken } from "@server/auth/sessions/app"; export const stripDuplicateSesions = async ( req: any, res: Response, next: NextFunction ) => { const cookieHeader: string | undefined = req.headers.cookie; if (!cookieHeader) { return next(); } const cookies = cookieHeader.split(";").map((cookie) => cookie.trim()); const sessionCookies = cookies.filter((cookie) => cookie.startsWith(`${SESSION_COOKIE_NAME}=`) ); const validSessions: string[] = []; if (sessionCookies.length > 1) { for (const cookie of sessionCookies) { const cookieValue = cookie.split("=")[1]; const res = await validateSessionToken(cookieValue); if (res.session && res.user) { validSessions.push(cookieValue); } } if (validSessions.length > 0) { const newCookieHeader = cookies.filter((cookie) => { if (cookie.startsWith(`${SESSION_COOKIE_NAME}=`)) { const cookieValue = cookie.split("=")[1]; return validSessions.includes(cookieValue); } return true; }); req.headers.cookie = newCookieHeader.join("; "); if (req.cookies) { req.cookies[SESSION_COOKIE_NAME] = validSessions[0]; } } } return next(); }; ================================================ FILE: server/middlewares/verifyAccessTokenAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { resourceAccessToken, resources, userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "@server/auth/canUserAccessResource"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyAccessTokenAccess( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; const accessTokenId = req.params.accessTokenId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } const [accessToken] = await db .select() .from(resourceAccessToken) .where(eq(resourceAccessToken.accessTokenId, accessTokenId)) .limit(1); if (!accessToken) { return next( createHttpError( HttpCode.NOT_FOUND, `Access token with ID ${accessTokenId} not found` ) ); } const resourceId = accessToken.resourceId; if (!resourceId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Access token with ID ${accessTokenId} does not have a resource ID` ) ); } try { const resource = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId!)) .limit(1); if (resource.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } if (!resource[0].orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Resource with ID ${resourceId} does not have an organization ID` ) ); } if (!req.userOrg) { const res = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, resource[0].orgId) ) ); req.userOrg = res[0]; } if (!req.userOrg) { next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } else { req.userOrgRoleId = req.userOrg.roleId; req.userOrgId = resource[0].orgId!; } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } const resourceAllowed = await canUserAccessResource({ userId, resourceId, roleId: req.userOrgRoleId! }); if (!resourceAllowed) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this resource" ) ); } next(); } catch (e) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying organization access" ) ); } } ================================================ FILE: server/middlewares/verifyAdmin.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { roles, userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyAdmin( req: Request, res: Response, next: NextFunction ) { const userId = req.user?.userId; const orgId = req.userOrgId; if (!orgId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User does not have orgId") ); } if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (!req.userOrg) { const userOrgRes = await db .select() .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId!))) .limit(1); req.userOrg = userOrgRes[0]; } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } const userRole = await db .select() .from(roles) .where(eq(roles.roleId, req.userOrg.roleId)) .limit(1); if (userRole.length === 0 || !userRole[0].isAdmin) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have Admin access" ) ); } return next(); } ================================================ FILE: server/middlewares/verifyApiKeyAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { userOrgs, apiKeys, apiKeyOrg } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyApiKeyAccess( req: Request, res: Response, next: NextFunction ) { try { const userId = req.user!.userId; const apiKeyId = req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId; const orgId = req.params.orgId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (!orgId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") ); } if (!apiKeyId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") ); } const [apiKey] = await db .select() .from(apiKeys) .innerJoin(apiKeyOrg, eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId)) .where( and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId)) ) .limit(1); if (!apiKey.apiKeys) { return next( createHttpError( HttpCode.NOT_FOUND, `API key with ID ${apiKeyId} not found` ) ); } if (!apiKeyOrg.orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `API key with ID ${apiKeyId} does not have an organization ID` ) ); } if (!req.userOrg) { const userOrgRole = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, apiKeyOrg.orgId) ) ) .limit(1); req.userOrg = userOrgRole[0]; } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying key access" ) ); } } ================================================ FILE: server/middlewares/verifyClientAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { Client, db } from "@server/db"; import { userOrgs, clients, roleClients, userClients } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import logger from "@server/logger"; export async function verifyClientAccess( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; // Assuming you have user information in the request const clientIdStr = req.params?.clientId || req.body?.clientId || req.query?.clientId; const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId; const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId; try { if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } let client: Client | null = null; if (niceId && orgId) { const [clientRes] = await db .select() .from(clients) .where( and(eq(clients.niceId, niceId), eq(clients.orgId, orgId)) ) .limit(1); client = clientRes; } else { const clientId = parseInt(clientIdStr); if (isNaN(clientId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid client ID") ); } // Get the client const [clientRes] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); client = clientRes; } if (!client) { return next( createHttpError( HttpCode.NOT_FOUND, `Client with ID ${niceId || clientIdStr} not found` ) ); } if (!client.orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Client with ID ${niceId || clientIdStr} does not have an organization ID` ) ); } if (!req.userOrg || req.userOrg?.orgId !== client.orgId) { // Get user's role ID in the organization const userOrgRole = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, client.orgId) ) ) .limit(1); req.userOrg = userOrgRole[0]; } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; req.userOrgId = client.orgId; // Check role-based site access first const [roleClientAccess] = await db .select() .from(roleClients) .where( and( eq(roleClients.clientId, client.clientId), eq(roleClients.roleId, userOrgRoleId) ) ) .limit(1); if (roleClientAccess) { // User has access to the site through their role return next(); } // If role doesn't have access, check user-specific site access const [userClientAccess] = await db .select() .from(userClients) .where( and( eq(userClients.userId, userId), eq(userClients.clientId, client.clientId) ) ) .limit(1); if (userClientAccess) { // User has direct access to the site return next(); } // If we reach here, the user doesn't have access to the site return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this client" ) ); } catch (error) { logger.error("Error verifying client access", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying site access" ) ); } } ================================================ FILE: server/middlewares/verifyDomainAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db, domains, orgDomains } from "@server/db"; import { userOrgs, apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyDomainAccess( req: Request, res: Response, next: NextFunction ) { try { const userId = req.user!.userId; const domainId = req.params.domainId || req.body.apiKeyId || req.query.apiKeyId; const orgId = req.params.orgId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (!orgId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") ); } if (!domainId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID") ); } const [domain] = await db .select() .from(domains) .innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId)) .where( and( eq(orgDomains.domainId, domainId), eq(orgDomains.orgId, orgId) ) ) .limit(1); if (!domain.orgDomains) { return next( createHttpError( HttpCode.NOT_FOUND, `Domain with ID ${domainId} not found` ) ); } if (!req.userOrg) { const userOrgRole = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, apiKeyOrg.orgId) ) ) .limit(1); req.userOrg = userOrgRole[0]; } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying domain access" ) ); } } ================================================ FILE: server/middlewares/verifyIsLoggedInUser.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyIsLoggedInUser( req: Request, res: Response, next: NextFunction ) { try { const userId = req.user!.userId; const reqUserId = req.params.userId || req.body.userId || req.query.userId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } // allow server admins to access any user if (req.user?.serverAdmin) { return next(); } if (reqUserId !== userId) { return next( createHttpError( HttpCode.FORBIDDEN, "User only has access to their own account" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error checking if user has access to this user" ) ); } } ================================================ FILE: server/middlewares/verifyLimits.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { usageService } from "@server/lib/billing/usageService"; import { build } from "@server/build"; export async function verifyLimits( req: Request, res: Response, next: NextFunction ) { if (build != "saas") { return next(); } const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.orgId; if (!orgId) { return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail } try { const reject = await usageService.checkLimitSet(orgId); if (reject) { return next( createHttpError( HttpCode.PAYMENT_REQUIRED, "Organization has exceeded its usage limits. Please upgrade your plan or contact support." ) ); } return next(); } catch (e) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error checking limits" ) ); } } ================================================ FILE: server/middlewares/verifyOlmAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { db, olms } from "@server/db"; import { and, eq } from "drizzle-orm"; export async function verifyOlmAccess( req: Request, res: Response, next: NextFunction ) { try { const userId = req.user!.userId; const olmId = req.params.olmId || req.body.olmId || req.query.olmId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } const [existingOlm] = await db .select() .from(olms) .where(and(eq(olms.olmId, olmId), eq(olms.userId, userId))); if (!existingOlm) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this olm" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error checking if user has access to this user" ) ); } } ================================================ FILE: server/middlewares/verifyOrgAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db, orgs } from "@server/db"; import { userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyOrgAccess( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; const orgId = req.params.orgId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (!orgId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") ); } try { if (!req.userOrg) { const userOrgRes = await db .select() .from(userOrgs) .where( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) ); req.userOrg = userOrgRes[0]; } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (req.orgPolicyAllowed === undefined) { const policyCheck = await checkOrgAccessPolicy({ orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } // User has access, attach the user's role to the request for potential future use req.userOrgRoleId = req.userOrg.roleId; req.userOrgId = orgId; return next(); } catch (e) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying organization access" ) ); } } ================================================ FILE: server/middlewares/verifyResourceAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db, Resource } from "@server/db"; import { resources, userOrgs, userResources, roleResources } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyResourceAccess( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; const resourceIdStr = req.params?.resourceId || req.body?.resourceId || req.query?.resourceId; const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId; const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId; try { if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } let resource: Resource | null = null; if (orgId && niceId) { const [resourceRes] = await db .select() .from(resources) .where( and( eq(resources.niceId, niceId), eq(resources.orgId, orgId) ) ) .limit(1); resource = resourceRes; } else { const resourceId = parseInt(resourceIdStr); const [resourceRes] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); resource = resourceRes; } if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceIdStr || niceId} not found` ) ); } if (!resource.orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Resource with ID ${resourceIdStr || niceId} does not have an organization ID` ) ); } if (!req.userOrg) { const userOrgRole = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, resource.orgId) ) ) .limit(1); req.userOrg = userOrgRole[0]; } if (!req.userOrg || req.userOrg?.orgId !== resource.orgId) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; req.userOrgId = resource.orgId; const roleResourceAccess = await db .select() .from(roleResources) .where( and( eq(roleResources.resourceId, resource.resourceId), eq(roleResources.roleId, userOrgRoleId) ) ) .limit(1); if (roleResourceAccess.length > 0) { return next(); } const userResourceAccess = await db .select() .from(userResources) .where( and( eq(userResources.userId, userId), eq(userResources.resourceId, resource.resourceId) ) ) .limit(1); if (userResourceAccess.length > 0) { return next(); } return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this resource" ) ); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying resource access" ) ); } } ================================================ FILE: server/middlewares/verifyRoleAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { roles, userOrgs } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyRoleAccess( req: Request, res: Response, next: NextFunction ) { const userId = req.user?.userId; const singleRoleId = parseInt( req.params.roleId || req.body.roleId || req.query.roleId ); if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } 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(); } try { const rolesData = await db .select() .from(roles) .where(inArray(roles.roleId, allRoleIds)); if (rolesData.length !== allRoleIds.length) { return next( createHttpError( HttpCode.NOT_FOUND, "One or more roles not found" ) ); } const orgIds = new Set(rolesData.map((role) => role.orgId)); // Check user access to each role's organization for (const role of rolesData) { const userOrgRole = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId!) ) ) .limit(1); if (userOrgRole.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, `User does not have access to organization for role ID ${role.roleId}` ) ); } req.userOrgId = role.orgId; } if (orgIds.size > 1) { return next( createHttpError( HttpCode.FORBIDDEN, "Roles must belong to the same organization" ) ); } const orgId = orgIds.values().next().value; if (!orgId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Organization ID not found" ) ); } if (!req.userOrg) { // get the userORg const userOrg = await db .select() .from(userOrgs) .where( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId!)) ) .limit(1); req.userOrg = userOrg[0]; req.userOrgRoleId = userOrg[0].roleId; } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } return next(); } catch (error) { logger.error("Error verifying role access:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying role access" ) ); } } ================================================ FILE: server/middlewares/verifySession.ts ================================================ import { NextFunction, Response } from "express"; import ErrorResponse from "@server/types/ErrorResponse"; import { verifySession } from "@server/auth/sessions/verifySession"; import { unauthorized } from "@server/auth/unauthorizedResponse"; export const verifySessionMiddleware = async ( req: any, res: Response, next: NextFunction ) => { const { forceLogin } = req.query; const { session, user } = await verifySession(req, forceLogin === "true"); if (!session || !user) { return next(unauthorized()); } req.user = user; req.session = session; return next(); }; ================================================ FILE: server/middlewares/verifySetResourceClients.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { clients } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifySetResourceClients( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; const singleClientId = req.params.clientId || req.body.clientId || req.query.clientId; const { clientIds } = req.body; const allClientIds = clientIds || (singleClientId ? [parseInt(singleClientId as string)] : []); if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } if (allClientIds.length === 0) { return next(); } try { const orgId = req.userOrg.orgId; // get all clients for the clientIds const clientsData = await db .select() .from(clients) .where( and( inArray(clients.clientId, allClientIds), eq(clients.orgId, orgId) ) ); if (clientsData.length !== allClientIds.length) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to one or more specified clients" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error checking if user has access to the specified clients" ) ); } } ================================================ FILE: server/middlewares/verifySetResourceUsers.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { userOrgs } from "@server/db"; import { and, eq, inArray, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifySetResourceUsers( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; const userIds = req.body.userIds; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this user" ) ); } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } if (!userIds) { return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); } if (userIds.length === 0) { return next(); } try { const orgId = req.userOrg.orgId; // get all userOrgs for the users const userOrgsData = await db .select() .from(userOrgs) .where( and( inArray(userOrgs.userId, userIds), eq(userOrgs.orgId, orgId) ) ); if (userOrgsData.length !== userIds.length) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this user" ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error checking if user has access to this user" ) ); } } ================================================ FILE: server/middlewares/verifySiteAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { sites, Site, userOrgs, userSites, roleSites, roles } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifySiteAccess( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; // Assuming you have user information in the request const siteIdStr = req.params?.siteId || req.body?.siteId || req.query?.siteId; const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId; const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } try { let site: Site | null = null; if (niceId && orgId) { const [siteRes] = await db .select() .from(sites) .where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId))) .limit(1); site = siteRes; } else { const siteId = parseInt(siteIdStr); if (isNaN(siteId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID") ); } // Get the site const [siteRes] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); site = siteRes; } if (!site) { return next( createHttpError( HttpCode.NOT_FOUND, `Site with ID ${siteIdStr || niceId} not found` ) ); } if (!site.orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Site with ID ${siteIdStr} does not have an organization ID` ) ); } if (!req.userOrg) { // Get user's role ID in the organization const userOrgRole = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, site.orgId) ) ) .limit(1); req.userOrg = userOrgRole[0]; } if (!req.userOrg || req.userOrg?.orgId !== site.orgId) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; req.userOrgId = site.orgId; // Check role-based site access first const roleSiteAccess = await db .select() .from(roleSites) .where( and( eq(roleSites.siteId, site.siteId), eq(roleSites.roleId, userOrgRoleId) ) ) .limit(1); if (roleSiteAccess.length > 0) { // User's role has access to the site return next(); } // If role doesn't have access, check user-specific site access const userSiteAccess = await db .select() .from(userSites) .where( and( eq(userSites.userId, userId), eq(userSites.siteId, site.siteId) ) ) .limit(1); if (userSiteAccess.length > 0) { // User has direct access to the site return next(); } // If we reach here, the user doesn't have access to the site return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this site" ) ); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying site access" ) ); } } ================================================ FILE: server/middlewares/verifySiteResourceAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db"; import { siteResources } from "@server/db"; import { eq, and } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifySiteResourceAccess( req: Request, res: Response, next: NextFunction ): Promise { try { const userId = req.user!.userId; const siteResourceId = req.params.siteResourceId || req.body.siteResourceId || req.query.siteResourceId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (!siteResourceId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Site resource ID is required" ) ); } const siteResourceIdNum = parseInt(siteResourceId as string, 10); if (isNaN(siteResourceIdNum)) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid site resource ID" ) ); } const [siteResource] = await db .select() .from(siteResources) .where(eq(siteResources.siteResourceId, siteResourceIdNum)) .limit(1); if (!siteResource) { return next( createHttpError( HttpCode.NOT_FOUND, `Site resource with ID ${siteResourceIdNum} not found` ) ); } if (!siteResource.orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Site resource with ID ${siteResourceIdNum} does not have an organization ID` ) ); } if (!req.userOrg) { const userOrgRole = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, siteResource.orgId) ) ) .limit(1); req.userOrg = userOrgRole[0]; } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; req.userOrgId = siteResource.orgId; // Attach the siteResource to the request for use in the next middleware/route req.siteResource = siteResource; const roleResourceAccess = await db .select() .from(roleSiteResources) .where( and( eq(roleSiteResources.siteResourceId, siteResourceIdNum), eq(roleSiteResources.roleId, userOrgRoleId) ) ) .limit(1); if (roleResourceAccess.length > 0) { return next(); } const userResourceAccess = await db .select() .from(userSiteResources) .where( and( eq(userSiteResources.userId, userId), eq(userSiteResources.siteResourceId, siteResourceIdNum) ) ) .limit(1); if (userResourceAccess.length > 0) { return next(); } return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this resource" ) ); } catch (error) { logger.error("Error verifying site resource access:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying site resource access" ) ); } } ================================================ FILE: server/middlewares/verifyTargetAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { resources, targets, userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "../auth/canUserAccessResource"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyTargetAccess( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; const targetId = parseInt(req.params.targetId); if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (isNaN(targetId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") ); } const target = await db .select() .from(targets) .where(eq(targets.targetId, targetId)) .limit(1); if (target.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Target with ID ${targetId} not found` ) ); } const resourceId = target[0].resourceId; if (!resourceId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Target with ID ${targetId} does not have a resource ID` ) ); } try { const resource = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId!)) .limit(1); if (resource.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } if (!resource[0].orgId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `resource with ID ${resourceId} does not have an organization ID` ) ); } if (!req.userOrg) { const res = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, resource[0].orgId) ) ); req.userOrg = res[0]; } if (!req.userOrg) { next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } else { req.userOrgRoleId = req.userOrg.roleId; req.userOrgId = resource[0].orgId!; } const orgId = req.userOrg.orgId; if (req.orgPolicyAllowed === undefined && orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } const resourceAllowed = await canUserAccessResource({ userId, resourceId, roleId: req.userOrgRoleId! }); if (!resourceAllowed) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this resource" ) ); } next(); } catch (e) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying organization access" ) ); } } ================================================ FILE: server/middlewares/verifyUser.ts ================================================ import { NextFunction, Response } from "express"; import ErrorResponse from "@server/types/ErrorResponse"; import { db } from "@server/db"; import { users } from "@server/db"; import { eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import config from "@server/lib/config"; import { verifySession } from "@server/auth/sessions/verifySession"; import { unauthorized } from "@server/auth/unauthorizedResponse"; import logger from "@server/logger"; export const verifySessionUserMiddleware = async ( req: any, res: Response, next: NextFunction ) => { const { forceLogin } = req.query; const { session, user } = await verifySession(req, forceLogin === "true"); if (!session || !user) { if (config.getRawConfig().app.log_failed_attempts) { logger.info(`User session not found. IP: ${req.ip}.`); } return next(unauthorized()); } const existingUser = await db .select() .from(users) .where(eq(users.userId, user.userId)); if (!existingUser || !existingUser[0]) { if (config.getRawConfig().app.log_failed_attempts) { logger.info(`User session not found. IP: ${req.ip}.`); } return next( createHttpError(HttpCode.BAD_REQUEST, "User does not exist") ); } req.user = existingUser[0]; req.session = session; if ( !existingUser[0].emailVerified && config.getRawConfig().flags?.require_email_verification ) { return next( createHttpError(HttpCode.BAD_REQUEST, "Email is not verified") // Might need to change the response type? ); } next(); }; ================================================ FILE: server/middlewares/verifyUserAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { userOrgs } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; export async function verifyUserAccess( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; const reqUserId = req.params.userId || req.body.userId || req.query.userId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (!reqUserId) { return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID")); } try { if (!req.userOrg) { const res = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, reqUserId), eq(userOrgs.orgId, req.userOrgId!) ) ) .limit(1); req.userOrg = res[0]; } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this user" ) ); } if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { const policyCheck = await checkOrgAccessPolicy({ orgId: req.userOrg.orgId, userId, session: req.session }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (policyCheck.error || "Unknown error") ) ); } } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error checking if user has access to this user" ) ); } } ================================================ FILE: server/middlewares/verifyUserHasAction.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { checkUserActionPermission } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions"; export function verifyUserHasAction(action: ActionsEnum) { return async function ( req: Request, res: Response, next: NextFunction ): Promise { try { const hasPermission = await checkUserActionPermission(action, req); if (!hasPermission) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have permission perform this action" ) ); } return next(); } catch (error) { logger.error("Error verifying role access:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying role access" ) ); } }; } ================================================ FILE: server/middlewares/verifyUserInRole.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; export async function verifyUserInRole( req: Request, res: Response, next: NextFunction ) { try { const roleId = parseInt( req.params.roleId || req.body.roleId || req.query.roleId ); const userRoleId = req.userOrgRoleId; if (isNaN(roleId)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID") ); } if (!userRoleId) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (userRoleId !== roleId) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this role" ) ); } return next(); } catch (error) { logger.error("Error verifying role access:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying role access" ) ); } } ================================================ FILE: server/middlewares/verifyUserIsOrgOwner.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyUserIsOrgOwner( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; const orgId = req.params.orgId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (!orgId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Organization ID not provided" ) ); } try { if (!req.userOrg) { const res = await db .select() .from(userOrgs) .where( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) ); req.userOrg = res[0]; } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } if (!req.userOrg.isOwner) { return next( createHttpError( HttpCode.FORBIDDEN, "User is not an organization owner" ) ); } return next(); } catch (e) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying organization access" ) ); } } ================================================ FILE: server/middlewares/verifyUserIsServerAdmin.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyUserIsServerAdmin( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } try { if (!req.user?.serverAdmin) { return next( createHttpError( HttpCode.FORBIDDEN, "User is not a server admin" ) ); } return next(); } catch (e) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying organization access" ) ); } } ================================================ FILE: server/nextServer.ts ================================================ import next from "next"; import express from "express"; import { parse } from "url"; import logger from "@server/logger"; import config from "@server/lib/config"; import { stripDuplicateSesions } from "./middlewares/stripDuplicateSessions"; const nextPort = config.getRawConfig().server.next_port; export async function createNextServer() { // const app = next({ dev }); const app = next({ dev: process.env.ENVIRONMENT !== "prod", turbopack: true }); const handle = app.getRequestHandler(); await app.prepare(); const nextServer = express(); nextServer.use(stripDuplicateSesions); nextServer.all("/{*splat}", (req, res) => { const parsedUrl = parse(req.url!, true); return handle(req, res, parsedUrl); }); nextServer.listen(nextPort, (err?: any) => { if (err) throw err; logger.info( `Next.js server is running on http://localhost:${nextPort}` ); }); return nextServer; } ================================================ FILE: server/openApi.ts ================================================ import { OpenAPIRegistry } from "@asteasolutions/zod-to-openapi"; export const registry = new OpenAPIRegistry(); export enum OpenAPITags { Site = "Site", Org = "Organization", PublicResource = "Public Resource", PrivateResource = "Private Resource", Role = "Role", User = "User", Invitation = "User Invitation", Target = "Resource Target", Rule = "Rule", AccessToken = "Access Token", GlobalIdp = "Identity Provider (Global)", OrgIdp = "Identity Provider (Organization Only)", Client = "Client", ApiKey = "API Key", Domain = "Domain", Blueprint = "Blueprint", Ssh = "SSH", Logs = "Logs" } ================================================ FILE: server/private/auth/sessions/remoteExitNode.ts ================================================ /* * 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 { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { RemoteExitNode, remoteExitNodes, remoteExitNodeSessions, RemoteExitNodeSession } from "@server/db"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; export const EXPIRES = 1000 * 60 * 60 * 24 * 30; export async function createRemoteExitNodeSession( token: string, remoteExitNodeId: string ): Promise { const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const session: RemoteExitNodeSession = { sessionId: sessionId, remoteExitNodeId, expiresAt: new Date(Date.now() + EXPIRES).getTime() }; await db.insert(remoteExitNodeSessions).values(session); return session; } export async function validateRemoteExitNodeSessionToken( token: string ): Promise { const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const result = await db .select({ remoteExitNode: remoteExitNodes, session: remoteExitNodeSessions }) .from(remoteExitNodeSessions) .innerJoin( remoteExitNodes, eq( remoteExitNodeSessions.remoteExitNodeId, remoteExitNodes.remoteExitNodeId ) ) .where(eq(remoteExitNodeSessions.sessionId, sessionId)); if (result.length < 1) { return { session: null, remoteExitNode: null }; } const { remoteExitNode, session } = result[0]; if (Date.now() >= session.expiresAt) { await db .delete(remoteExitNodeSessions) .where(eq(remoteExitNodeSessions.sessionId, session.sessionId)); return { session: null, remoteExitNode: null }; } if (Date.now() >= session.expiresAt - EXPIRES / 2) { session.expiresAt = new Date(Date.now() + EXPIRES).getTime(); await db .update(remoteExitNodeSessions) .set({ expiresAt: session.expiresAt }) .where(eq(remoteExitNodeSessions.sessionId, session.sessionId)); } return { session, remoteExitNode }; } export async function invalidateRemoteExitNodeSession( sessionId: string ): Promise { await db .delete(remoteExitNodeSessions) .where(eq(remoteExitNodeSessions.sessionId, sessionId)); } export async function invalidateAllRemoteExitNodeSessions( remoteExitNodeId: string ): Promise { await db .delete(remoteExitNodeSessions) .where(eq(remoteExitNodeSessions.remoteExitNodeId, remoteExitNodeId)); } export type SessionValidationResult = | { session: RemoteExitNodeSession; remoteExitNode: RemoteExitNode } | { session: null; remoteExitNode: null }; ================================================ FILE: server/private/cleanup.ts ================================================ /* * 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 { rateLimitService } from "#private/lib/rateLimit"; import { cleanup as wsCleanup } from "#private/routers/ws"; import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage"; import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"; async function cleanup() { await flushBandwidthToDb(); await flushSiteBandwidthToDb(); await rateLimitService.cleanup(); await wsCleanup(); process.exit(0); } export async function initCleanup() { // Handle process termination process.on("SIGTERM", () => cleanup()); process.on("SIGINT", () => cleanup()); } ================================================ FILE: server/private/lib/billing/createCustomer.ts ================================================ /* * 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 { customers, db } from "@server/db"; import { eq } from "drizzle-orm"; import stripe from "#private/lib/stripe"; import { build } from "@server/build"; export async function createCustomer( orgId: string, email: string | null | undefined ): Promise { if (build !== "saas") { return; } const [customer] = await db .select() .from(customers) .where(eq(customers.orgId, orgId)) .limit(1); let customerId: string; // If we don't have a customer, create one if (!customer) { const newCustomer = await stripe!.customers.create({ metadata: { orgId: orgId }, email: email || undefined }); customerId = newCustomer.id; // It will get inserted into the database by the webhook } else { customerId = customer.customerId; } return customerId; } ================================================ FILE: server/private/lib/billing/getOrgTierData.ts ================================================ /* * 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 { build } from "@server/build"; 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"; export async function getOrgTierData( orgId: string ): Promise<{ tier: Tier | null; active: boolean }> { let tier: Tier | null = null; let active = false; if (build !== "saas") { return { tier, active }; } 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, orgIdToUse)) .limit(1); if (!customer) { return { tier, active }; } // 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) { // If org not found or error occurs, return null tier and inactive // This is acceptable behavior as per the function signature } return { tier, active }; } ================================================ FILE: server/private/lib/billing/index.ts ================================================ /* * 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 "./getOrgTierData"; export * from "./createCustomer"; ================================================ FILE: server/private/lib/blueprints/MaintenanceSchema.ts ================================================ /* * 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 { z } from "zod"; export const MaintenanceSchema = z.object({ enabled: z.boolean().optional(), type: z.enum(["forced", "automatic"]).optional(), title: z.string().max(255).nullable().optional(), message: z.string().max(2000).nullable().optional(), "estimated-time": z.string().max(100).nullable().optional() }); ================================================ FILE: server/private/lib/cache.ts ================================================ import NodeCache from "node-cache"; import logger from "@server/logger"; import { redisManager } from "@server/private/lib/redis"; // Create local cache with maxKeys limit to prevent memory leaks // With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient export const localCache = new NodeCache({ stdTTL: 3600, checkperiod: 120, maxKeys: 10000 }); // Log cache statistics periodically for monitoring setInterval(() => { const stats = localCache.getStats(); logger.debug( `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%` ); }, 300000); // Every 5 minutes /** * Adaptive cache that uses Redis when available in multi-node environments, * otherwise falls back to local memory cache for single-node deployments. */ class AdaptiveCache { private useRedis(): boolean { return redisManager.isRedisEnabled() && redisManager.getHealthStatus().isHealthy; } /** * Set a value in the cache * @param key - Cache key * @param value - Value to cache (will be JSON stringified for Redis) * @param ttl - Time to live in seconds (0 = no expiration) * @returns boolean indicating success */ async set(key: string, value: any, ttl?: number): Promise { const effectiveTtl = ttl === 0 ? undefined : ttl; if (this.useRedis()) { try { const serialized = JSON.stringify(value); const success = await redisManager.set(key, serialized, effectiveTtl); if (success) { logger.debug(`Set key in Redis: ${key}`); return true; } // Redis failed, fall through to local cache logger.debug(`Redis set failed for key ${key}, falling back to local cache`); } catch (error) { logger.error(`Redis set error for key ${key}:`, error); // Fall through to local cache } } // Use local cache as fallback or primary const success = localCache.set(key, value, effectiveTtl || 0); if (success) { logger.debug(`Set key in local cache: ${key}`); } return success; } /** * Get a value from the cache * @param key - Cache key * @returns The cached value or undefined if not found */ async get(key: string): Promise { if (this.useRedis()) { try { const value = await redisManager.get(key); if (value !== null) { logger.debug(`Cache hit in Redis: ${key}`); return JSON.parse(value) as T; } logger.debug(`Cache miss in Redis: ${key}`); return undefined; } catch (error) { logger.error(`Redis get error for key ${key}:`, error); // Fall through to local cache } } // Use local cache as fallback or primary const value = localCache.get(key); if (value !== undefined) { logger.debug(`Cache hit in local cache: ${key}`); } else { logger.debug(`Cache miss in local cache: ${key}`); } return value; } /** * Delete a value from the cache * @param key - Cache key or array of keys * @returns Number of deleted entries */ async del(key: string | string[]): Promise { const keys = Array.isArray(key) ? key : [key]; let deletedCount = 0; if (this.useRedis()) { try { for (const k of keys) { const success = await redisManager.del(k); if (success) { deletedCount++; logger.debug(`Deleted key from Redis: ${k}`); } } if (deletedCount === keys.length) { return deletedCount; } // Some Redis deletes failed, fall through to local cache logger.debug(`Some Redis deletes failed, falling back to local cache`); } catch (error) { logger.error(`Redis del error for keys ${keys.join(", ")}:`, error); // Fall through to local cache deletedCount = 0; } } // Use local cache as fallback or primary for (const k of keys) { const success = localCache.del(k); if (success > 0) { deletedCount++; logger.debug(`Deleted key from local cache: ${k}`); } } return deletedCount; } /** * Check if a key exists in the cache * @param key - Cache key * @returns boolean indicating if key exists */ async has(key: string): Promise { if (this.useRedis()) { try { const value = await redisManager.get(key); return value !== null; } catch (error) { logger.error(`Redis has error for key ${key}:`, error); // Fall through to local cache } } // Use local cache as fallback or primary return localCache.has(key); } /** * Get multiple values from the cache * @param keys - Array of cache keys * @returns Array of values (undefined for missing keys) */ async mget(keys: string[]): Promise<(T | undefined)[]> { if (this.useRedis()) { try { const results: (T | undefined)[] = []; for (const key of keys) { const value = await redisManager.get(key); if (value !== null) { results.push(JSON.parse(value) as T); } else { results.push(undefined); } } return results; } catch (error) { logger.error(`Redis mget error:`, error); // Fall through to local cache } } // Use local cache as fallback or primary return keys.map((key) => localCache.get(key)); } /** * Flush all keys from the cache */ async flushAll(): Promise { if (this.useRedis()) { logger.warn("Adaptive cache flushAll called - Redis flush not implemented, only local cache will be flushed"); } localCache.flushAll(); logger.debug("Flushed local cache"); } /** * Get cache statistics * Note: Only returns local cache stats, Redis stats are not included */ getStats() { return localCache.getStats(); } /** * Get the current cache backend being used * @returns "redis" if Redis is available and healthy, "local" otherwise */ getCurrentBackend(): "redis" | "local" { return this.useRedis() ? "redis" : "local"; } /** * Take a key from the cache and delete it * @param key - Cache key * @returns The value or undefined if not found */ async take(key: string): Promise { const value = await this.get(key); if (value !== undefined) { await this.del(key); } return value; } /** * Get TTL (time to live) for a key * @param key - Cache key * @returns TTL in seconds, 0 if no expiration, -1 if key doesn't exist */ getTtl(key: string): number { // Note: This only works for local cache, Redis TTL is not supported if (this.useRedis()) { logger.warn(`getTtl called for key ${key} but Redis TTL lookup is not implemented`); } const ttl = localCache.getTtl(key); if (ttl === undefined) { return -1; } return Math.max(0, Math.floor((ttl - Date.now()) / 1000)); } /** * Get all keys from the cache * Note: Only returns local cache keys, Redis keys are not included */ keys(): string[] { if (this.useRedis()) { logger.warn("keys() called but Redis keys are not included, only local cache keys returned"); } return localCache.keys(); } } // Export singleton instance export const cache = new AdaptiveCache(); export default cache; ================================================ FILE: server/private/lib/certificates.ts ================================================ /* * 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 config from "./config"; import { certificates, db } from "@server/db"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { decryptData } from "@server/lib/encryption"; import logger from "@server/logger"; import cache from "#private/lib/cache"; let encryptionKeyHex = ""; let encryptionKey: Buffer; function loadEncryptData() { if (encryptionKey) { return; // already loaded } encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key; encryptionKey = Buffer.from(encryptionKeyHex, "hex"); } // Define the return type for clarity and type safety export type CertificateResult = { id: number; domain: string; queriedDomain: string; // The domain that was originally requested (may differ for wildcards) wildcard: boolean | null; certFile: string | null; keyFile: string | null; expiresAt: number | null; updatedAt?: number | null; }; export async function getValidCertificatesForDomains( domains: Set, useCache: boolean = true ): Promise> { loadEncryptData(); // Ensure encryption key is loaded const finalResults: CertificateResult[] = []; const domainsToQuery = new Set(); // 1. Check cache first if enabled if (useCache) { for (const domain of domains) { const cacheKey = `cert:${domain}`; const cachedCert = await cache.get(cacheKey); if (cachedCert) { finalResults.push(cachedCert); // Valid cache hit } else { domainsToQuery.add(domain); // Cache miss or expired } } } else { // If caching is disabled, add all domains to the query set domains.forEach((d) => domainsToQuery.add(d)); } // 2. If all domains were resolved from the cache, return early if (domainsToQuery.size === 0) { const decryptedResults = decryptFinalResults(finalResults); return decryptedResults; } // 3. Prepare domains for the database query const domainsToQueryArray = Array.from(domainsToQuery); const parentDomainsToQuery = new Set(); domainsToQueryArray.forEach((domain) => { const parts = domain.split("."); // A wildcard can only match a domain with at least two parts (e.g., example.com) if (parts.length > 1) { parentDomainsToQuery.add(parts.slice(1).join(".")); } }); const parentDomainsArray = Array.from(parentDomainsToQuery); // 4. Build and execute a single, efficient Drizzle query // This query fetches all potential exact and wildcard matches in one database round-trip. const potentialCerts = await db .select() .from(certificates) .where( and( eq(certificates.status, "valid"), isNotNull(certificates.certFile), isNotNull(certificates.keyFile), or( // Condition for exact matches on the requested domains inArray(certificates.domain, domainsToQueryArray), // Condition for wildcard matches on the parent domains parentDomainsArray.length > 0 ? and( inArray(certificates.domain, parentDomainsArray), eq(certificates.wildcard, true) ) : // If there are no possible parent domains, this condition is false sql`false` ) ) ); // 5. Process the database results, prioritizing exact matches over wildcards const exactMatches = new Map(); const wildcardMatches = new Map(); for (const cert of potentialCerts) { if (cert.wildcard) { wildcardMatches.set(cert.domain, cert); } else { exactMatches.set(cert.domain, cert); } } for (const domain of domainsToQuery) { let foundCert: (typeof potentialCerts)[0] | undefined = undefined; // Priority 1: Check for an exact match (non-wildcard) if (exactMatches.has(domain)) { foundCert = exactMatches.get(domain); } // Priority 2: Check for a wildcard certificate that matches the exact domain else { if (wildcardMatches.has(domain)) { foundCert = wildcardMatches.get(domain); } // Priority 3: Check for a wildcard match on the parent domain else { const parts = domain.split("."); if (parts.length > 1) { const parentDomain = parts.slice(1).join("."); if (wildcardMatches.has(parentDomain)) { foundCert = wildcardMatches.get(parentDomain); } } } } // If a certificate was found, format it, add to results, and cache it if (foundCert) { logger.debug( `Creating result cert for ${domain} using cert from ${foundCert.domain}` ); const resultCert: CertificateResult = { id: foundCert.certId, domain: foundCert.domain, // The actual domain of the cert record queriedDomain: domain, // The domain that was originally requested wildcard: foundCert.wildcard, certFile: foundCert.certFile, keyFile: foundCert.keyFile, expiresAt: foundCert.expiresAt, updatedAt: foundCert.updatedAt }; finalResults.push(resultCert); // Add to cache for future requests, using the *requested domain* as the key if (useCache) { const cacheKey = `cert:${domain}`; await cache.set(cacheKey, resultCert, 180); } } } const decryptedResults = decryptFinalResults(finalResults); return decryptedResults; } function decryptFinalResults( finalResults: CertificateResult[] ): CertificateResult[] { const validCertsDecrypted = finalResults.map((cert) => { // Decrypt and save certificate file const decryptedCert = decryptData( cert.certFile!, // is not null from query encryptionKey ); // Decrypt and save key file const decryptedKey = decryptData(cert.keyFile!, encryptionKey); // Return only the certificate data without org information return { ...cert, certFile: decryptedCert, keyFile: decryptedKey }; }); return validCertsDecrypted; } ================================================ FILE: server/private/lib/checkOrgAccessPolicy.ts ================================================ /* * 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 { build } from "@server/build"; import { db, Org, orgs, ResourceSession, sessions, users } from "@server/db"; import license from "#private/license/license"; import { eq } from "drizzle-orm"; import { CheckOrgAccessPolicyProps, CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; import { UserType } from "@server/types/UserTypes"; export function enforceResourceSessionLength( resourceSession: ResourceSession, org: Org ): { valid: boolean; error?: string } { if (org.maxSessionLengthHours) { const sessionIssuedAt = resourceSession.issuedAt; // may be null const maxSessionLengthHours = org.maxSessionLengthHours; if (sessionIssuedAt) { const maxSessionLengthMs = maxSessionLengthHours * 60 * 60 * 1000; const sessionAgeMs = Date.now() - sessionIssuedAt; if (sessionAgeMs > maxSessionLengthMs) { return { valid: false, error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)` }; } } else { return { valid: false, error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)` }; } } return { valid: true }; } export async function checkOrgAccessPolicy( props: CheckOrgAccessPolicyProps ): Promise { const userId = props.userId || props.user?.userId; const orgId = props.orgId || props.org?.orgId; const sessionId = props.sessionId || props.session?.sessionId; if (!orgId) { return { allowed: false, error: "Organization ID is required" }; } if (!userId) { return { allowed: false, error: "User ID is required" }; } if (!sessionId) { return { allowed: false, error: "Session ID is required" }; } if (build === "enterprise") { const isUnlocked = await license.isUnlocked(); // if not licensed, don't check the policies if (!isUnlocked) { return { allowed: true }; } } // TODO: check that the org is subscribed // get the needed data if (!props.org) { const [orgQuery] = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)); props.org = orgQuery; if (!props.org) { return { allowed: false, error: "Organization not found" }; } } if (!props.user) { const [userQuery] = await db .select() .from(users) .where(eq(users.userId, userId)); props.user = userQuery; if (!props.user) { return { allowed: false, error: "User not found" }; } } if (!props.session) { const [sessionQuery] = await db .select() .from(sessions) .where(eq(sessions.sessionId, sessionId)); props.session = sessionQuery; if (!props.session) { return { allowed: false, error: "Session not found" }; } } if (props.session.userId !== props.user.userId) { return { allowed: false, error: "Session does not belong to the user" }; } // now check the policies const policies: CheckOrgAccessPolicyResult["policies"] = {}; // only applies to internal users; oidc users 2fa is managed by the IDP if (props.user.type === UserType.Internal && props.org.requireTwoFactor) { policies.requiredTwoFactor = props.user.twoFactorEnabled || false; } // applies to all users if (props.org.maxSessionLengthHours) { const sessionIssuedAt = props.session.issuedAt; // may be null const maxSessionLengthHours = props.org.maxSessionLengthHours; if (sessionIssuedAt) { const maxSessionLengthMs = maxSessionLengthHours * 60 * 60 * 1000; const sessionAgeMs = Date.now() - sessionIssuedAt; policies.maxSessionLength = { compliant: sessionAgeMs <= maxSessionLengthMs, maxSessionLengthHours, sessionAgeHours: sessionAgeMs / (60 * 60 * 1000) }; } else { policies.maxSessionLength = { compliant: false, maxSessionLengthHours, sessionAgeHours: maxSessionLengthHours }; } } // only applies to internal users; oidc users don't have passwords if (props.user.type === UserType.Internal && props.org.passwordExpiryDays) { if (props.user.lastPasswordChange) { const passwordExpiryDays = props.org.passwordExpiryDays; const passwordAgeMs = Date.now() - props.user.lastPasswordChange; const passwordAgeDays = passwordAgeMs / (24 * 60 * 60 * 1000); policies.passwordAge = { compliant: passwordAgeDays <= passwordExpiryDays, maxPasswordAgeDays: passwordExpiryDays, passwordAgeDays: passwordAgeDays }; } else { policies.passwordAge = { compliant: false, maxPasswordAgeDays: props.org.passwordExpiryDays, passwordAgeDays: props.org.passwordExpiryDays // Treat as expired }; } } let allowed = true; if (policies.requiredTwoFactor === false) { allowed = false; } if ( policies.maxSessionLength && policies.maxSessionLength.compliant === false ) { allowed = false; } if (policies.passwordAge && policies.passwordAge.compliant === false) { allowed = false; } return { allowed, policies }; } ================================================ FILE: server/private/lib/config.ts ================================================ /* * 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 { z } from "zod"; import { __DIRNAME } from "@server/lib/consts"; import { SupporterKey } from "@server/db"; import { fromError } from "zod-validation-error"; import { privateConfigSchema, readPrivateConfigFile } from "#private/lib/readConfigFile"; export class PrivateConfig { private rawPrivateConfig!: z.infer; supporterData: SupporterKey | null = null; supporterHiddenUntil: number | null = null; isDev: boolean = process.env.ENVIRONMENT !== "prod"; constructor() { const privateEnvironment = readPrivateConfigFile(); const { data: parsedPrivateConfig, success: privateSuccess, error: privateError } = privateConfigSchema.safeParse(privateEnvironment); if (!privateSuccess) { const errors = fromError(privateError); throw new Error(`Invalid private configuration file: ${errors}`); } this.rawPrivateConfig = parsedPrivateConfig; process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER = this.rawPrivateConfig.branding?.hide_auth_layout_footer === true ? "true" : "false"; if (this.rawPrivateConfig.branding?.colors) { process.env.BRANDING_COLORS = JSON.stringify( this.rawPrivateConfig.branding?.colors ); } if (this.rawPrivateConfig.branding?.logo?.light_path) { process.env.BRANDING_LOGO_LIGHT_PATH = this.rawPrivateConfig.branding?.logo?.light_path; } if (this.rawPrivateConfig.branding?.logo?.dark_path) { process.env.BRANDING_LOGO_DARK_PATH = this.rawPrivateConfig.branding?.logo?.dark_path || undefined; } if (this.rawPrivateConfig.app.identity_provider_mode) { process.env.IDENTITY_PROVIDER_MODE = this.rawPrivateConfig.app.identity_provider_mode; } process.env.BRANDING_LOGO_AUTH_WIDTH = this.rawPrivateConfig.branding ?.logo?.auth_page?.width ? this.rawPrivateConfig.branding?.logo?.auth_page?.width.toString() : undefined; process.env.BRANDING_LOGO_AUTH_HEIGHT = this.rawPrivateConfig.branding ?.logo?.auth_page?.height ? this.rawPrivateConfig.branding?.logo?.auth_page?.height.toString() : undefined; process.env.BRANDING_LOGO_NAVBAR_WIDTH = this.rawPrivateConfig.branding ?.logo?.navbar?.width ? this.rawPrivateConfig.branding?.logo?.navbar?.width.toString() : undefined; process.env.BRANDING_LOGO_NAVBAR_HEIGHT = this.rawPrivateConfig.branding ?.logo?.navbar?.height ? this.rawPrivateConfig.branding?.logo?.navbar?.height.toString() : undefined; process.env.BRANDING_APP_NAME = this.rawPrivateConfig.branding?.app_name || "Pangolin"; if (this.rawPrivateConfig.branding?.footer) { process.env.BRANDING_FOOTER = JSON.stringify( this.rawPrivateConfig.branding?.footer ); } process.env.LOGIN_PAGE_SUBTITLE_TEXT = this.rawPrivateConfig.branding?.login_page?.subtitle_text || ""; process.env.SIGNUP_PAGE_SUBTITLE_TEXT = this.rawPrivateConfig.branding?.signup_page?.subtitle_text || ""; process.env.RESOURCE_AUTH_PAGE_HIDE_POWERED_BY = this.rawPrivateConfig.branding?.resource_auth_page ?.hide_powered_by === true ? "true" : "false"; process.env.RESOURCE_AUTH_PAGE_SHOW_LOGO = this.rawPrivateConfig.branding?.resource_auth_page?.show_logo === true ? "true" : "false"; process.env.RESOURCE_AUTH_PAGE_TITLE_TEXT = this.rawPrivateConfig.branding?.resource_auth_page?.title_text || ""; process.env.RESOURCE_AUTH_PAGE_SUBTITLE_TEXT = this.rawPrivateConfig.branding?.resource_auth_page?.subtitle_text || ""; if (this.rawPrivateConfig.branding?.background_image_path) { process.env.BACKGROUND_IMAGE_PATH = this.rawPrivateConfig.branding?.background_image_path; } if (this.rawPrivateConfig.server.reo_client_id) { process.env.REO_CLIENT_ID = this.rawPrivateConfig.server.reo_client_id; } if (this.rawPrivateConfig.flags.use_pangolin_dns) { process.env.USE_PANGOLIN_DNS = this.rawPrivateConfig.flags.use_pangolin_dns.toString(); } } public getRawPrivateConfig() { return this.rawPrivateConfig; } } export const privateConfig = new PrivateConfig(); export default privateConfig; ================================================ FILE: server/private/lib/exitNodes/exitNodeComms.ts ================================================ /* * 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 axios from "axios"; import logger from "@server/logger"; import { db, ExitNode, remoteExitNodes } from "@server/db"; import { eq } from "drizzle-orm"; import { sendToClient } from "#private/routers/ws"; import privateConfig from "#private/lib/config"; import config from "@server/lib/config"; interface ExitNodeRequest { remoteType?: string; localPath: string; method?: "POST" | "DELETE" | "GET" | "PUT"; data?: any; queryParams?: Record; } /** * Sends a request to an exit node, handling both remote and local exit nodes * @param exitNode The exit node to send the request to * @param request The request configuration * @returns Promise Response data for local nodes, undefined for remote nodes */ export async function sendToExitNode( exitNode: ExitNode, request: ExitNodeRequest ): Promise { if (exitNode.type === "remoteExitNode" && request.remoteType) { const [remoteExitNode] = await db .select() .from(remoteExitNodes) .where(eq(remoteExitNodes.exitNodeId, exitNode.exitNodeId)) .limit(1); if (!remoteExitNode) { throw new Error( `Remote exit node with ID ${exitNode.exitNodeId} not found` ); } return sendToClient( remoteExitNode.remoteExitNodeId, { type: request.remoteType, data: request.data }, { incrementConfigVersion: true } ); } else { let hostname = exitNode.reachableAt; // logger.debug(`Exit node details:`, { // type: exitNode.type, // name: exitNode.name, // reachableAt: exitNode.reachableAt, // }); // logger.debug(`Configured local exit node name: ${config.getRawConfig().gerbil.exit_node_name}`); if (exitNode.name == config.getRawConfig().gerbil.exit_node_name) { hostname = privateConfig.getRawPrivateConfig().gerbil .local_exit_node_reachable_at; } if (!hostname) { throw new Error( `Exit node with ID ${exitNode.exitNodeId} is not reachable` ); } // logger.debug(`Sending request to exit node at ${hostname}`, { // type: request.remoteType, // data: request.data // }); // Handle local exit node with HTTP API const method = request.method || "POST"; let url = `${hostname}${request.localPath}`; // Add query parameters if provided if (request.queryParams) { const params = new URLSearchParams(request.queryParams); url += `?${params.toString()}`; } try { let response; switch (method) { case "POST": response = await axios.post(url, request.data, { headers: { "Content-Type": "application/json" }, timeout: 8000 }); break; case "DELETE": response = await axios.delete(url, { timeout: 8000 }); break; case "GET": response = await axios.get(url, { timeout: 8000 }); break; case "PUT": response = await axios.put(url, request.data, { headers: { "Content-Type": "application/json" }, timeout: 8000 }); break; default: throw new Error(`Unsupported HTTP method: ${method}`); } logger.debug(`Exit node request successful:`, { method, url, status: response.data.status }); return response.data; } catch (error) { if (axios.isAxiosError(error)) { logger.error( `Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${hostname} (status: ${error.response?.status}): ${error.message}` ); } else { logger.error( `Error making ${method} request for exit node at ${hostname}: ${error}` ); } } } } ================================================ FILE: server/private/lib/exitNodes/exitNodes.ts ================================================ /* * 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 { db, exitNodes, exitNodeOrgs, resources, targets, sites, targetHealthCheck, Transaction } from "@server/db"; import logger from "@server/logger"; import { ExitNodePingResult } from "@server/routers/newt"; import { eq, and, or, ne, isNull } from "drizzle-orm"; import axios from "axios"; import config from "../config"; /** * Checks if an exit node is actually online by making HTTP requests to its endpoint/ping * Makes up to 3 attempts in parallel with small delays, returns as soon as one succeeds */ async function checkExitNodeOnlineStatus( endpoint: string | undefined ): Promise { if (!endpoint || endpoint == "") { // the endpoint can start out as a empty string return false; } const maxAttempts = 3; const timeoutMs = 5000; // 5 second timeout per request const delayBetweenAttempts = 100; // 100ms delay between starting each attempt // Create promises for all attempts with staggered delays const attemptPromises = Array.from( { length: maxAttempts }, async (_, index) => { const attemptNumber = index + 1; // Add delay before each attempt (except the first) if (index > 0) { await new Promise((resolve) => setTimeout(resolve, delayBetweenAttempts * index) ); } try { const response = await axios.get(`http://${endpoint}/ping`, { timeout: timeoutMs, validateStatus: (status) => status === 200 }); if (response.status === 200) { logger.debug( `Exit node ${endpoint} is online (attempt ${attemptNumber}/${maxAttempts})` ); return { success: true, attemptNumber }; } return { success: false, attemptNumber, error: "Non-200 status" }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; logger.debug( `Exit node ${endpoint} ping failed (attempt ${attemptNumber}/${maxAttempts}): ${errorMessage}` ); return { success: false, attemptNumber, error: errorMessage }; } } ); try { // Wait for the first successful response or all to fail const results = await Promise.allSettled(attemptPromises); // Check if any attempt succeeded for (const result of results) { if (result.status === "fulfilled" && result.value.success) { return true; } } // All attempts failed logger.warn( `Exit node ${endpoint} is offline after ${maxAttempts} parallel attempts` ); return false; } catch (error) { logger.warn( `Unexpected error checking exit node ${endpoint}: ${error instanceof Error ? error.message : "Unknown error"}` ); return false; } } export async function verifyExitNodeOrgAccess( exitNodeId: number, orgId: string ) { const [result] = await db .select({ exitNode: exitNodes, exitNodeOrgId: exitNodeOrgs.exitNodeId }) .from(exitNodes) .leftJoin( exitNodeOrgs, and( eq(exitNodeOrgs.exitNodeId, exitNodes.exitNodeId), eq(exitNodeOrgs.orgId, orgId) ) ) .where(eq(exitNodes.exitNodeId, exitNodeId)); if (!result) { return { hasAccess: false, exitNode: null }; } const { exitNode } = result; // If the exit node is type "gerbil", access is allowed if (exitNode.type === "gerbil") { return { hasAccess: true, exitNode }; } // If the exit node is type "remoteExitNode", check if it has org access if (exitNode.type === "remoteExitNode") { return { hasAccess: !!result.exitNodeOrgId, exitNode }; } // For any other type, deny access return { hasAccess: false, exitNode }; } export async function listExitNodes( orgId: string, filterOnline = false, noCloud = false ) { const allExitNodes = await db .select({ exitNodeId: exitNodes.exitNodeId, name: exitNodes.name, address: exitNodes.address, endpoint: exitNodes.endpoint, publicKey: exitNodes.publicKey, listenPort: exitNodes.listenPort, reachableAt: exitNodes.reachableAt, maxConnections: exitNodes.maxConnections, online: exitNodes.online, lastPing: exitNodes.lastPing, type: exitNodes.type, orgId: exitNodeOrgs.orgId, region: exitNodes.region }) .from(exitNodes) .leftJoin( exitNodeOrgs, eq(exitNodes.exitNodeId, exitNodeOrgs.exitNodeId) ) .where( or( // Include all exit nodes that are NOT of type remoteExitNode and( eq(exitNodes.type, "gerbil"), or( // only choose nodes that are in the same region eq( exitNodes.region, config.getRawPrivateConfig().app.region ), isNull(exitNodes.region) // or for enterprise where region is not set ) ), // Include remoteExitNode types where the orgId matches the newt's organization and( eq(exitNodes.type, "remoteExitNode"), eq(exitNodeOrgs.orgId, orgId) ) ) ); // Filter the nodes. If there are NO remoteExitNodes then do nothing. If there are then remove all of the non-remoteExitNodes if (allExitNodes.length === 0) { logger.warn("No exit nodes found for ping request!"); return []; } // // Enhanced online checking: consider node offline if either DB says offline OR HTTP ping fails // const nodesWithRealOnlineStatus = await Promise.all( // allExitNodes.map(async (node) => { // // If database says it's online, verify with HTTP ping // let online: boolean; // if (filterOnline && node.type == "remoteExitNode") { // try { // const isActuallyOnline = await checkExitNodeOnlineStatus( // node.endpoint // ); // // set the item in the database if it is offline // if (isActuallyOnline != node.online) { // await trx // .update(exitNodes) // .set({ online: isActuallyOnline }) // .where(eq(exitNodes.exitNodeId, node.exitNodeId)); // } // online = isActuallyOnline; // } catch (error) { // logger.warn( // `Failed to check online status for exit node ${node.name} (${node.endpoint}): ${error instanceof Error ? error.message : "Unknown error"}` // ); // online = false; // } // } else { // online = node.online; // } // return { // ...node, // online // }; // }) // ); const remoteExitNodes = allExitNodes.filter( (node) => node.type === "remoteExitNode" && (!filterOnline || node.online) ); const gerbilExitNodes = allExitNodes.filter( (node) => node.type === "gerbil" && (!filterOnline || node.online) && !noCloud ); // THIS PROVIDES THE FALL const exitNodesList = remoteExitNodes.length > 0 ? remoteExitNodes : gerbilExitNodes; return exitNodesList; } /** * Selects the most suitable exit node from a list of ping results. * * The selection algorithm follows these steps: * * 1. **Filter Invalid Nodes**: Excludes nodes with errors or zero weight. * * 2. **Sort by Latency**: Sorts valid nodes in ascending order of latency. * * 3. **Preferred Selection**: * - If the lowest-latency node has sufficient capacity (≥10% weight), * check if a previously connected node is also acceptable. * - The previously connected node is preferred if its latency is within * 30ms or 15% of the best node’s latency. * * 4. **Fallback to Next Best**: * - If the lowest-latency node is under capacity, find the next node * with acceptable capacity. * * 5. **Final Fallback**: * - If no nodes meet the capacity threshold, fall back to the node * with the highest weight (i.e., most available capacity). * */ export function selectBestExitNode( pingResults: ExitNodePingResult[] ): ExitNodePingResult | null { const MIN_CAPACITY_THRESHOLD = 0.1; const LATENCY_TOLERANCE_MS = 30; const LATENCY_TOLERANCE_PERCENT = 0.15; // Filter out invalid nodes const validNodes = pingResults.filter((n) => !n.error && n.weight > 0); if (validNodes.length === 0) { logger.debug("No valid exit nodes available"); return null; } // Sort by latency (ascending) const sortedNodes = validNodes .slice() .sort((a, b) => a.latencyMs - b.latencyMs); const lowestLatencyNode = sortedNodes[0]; logger.debug( `Lowest latency node: ${lowestLatencyNode.exitNodeName} (${lowestLatencyNode.latencyMs} ms, weight=${lowestLatencyNode.weight.toFixed(2)})` ); // If lowest latency node has enough capacity, check if previously connected node is acceptable if (lowestLatencyNode.weight >= MIN_CAPACITY_THRESHOLD) { const previouslyConnectedNode = sortedNodes.find( (n) => n.wasPreviouslyConnected && n.weight >= MIN_CAPACITY_THRESHOLD ); if (previouslyConnectedNode) { const latencyDiff = previouslyConnectedNode.latencyMs - lowestLatencyNode.latencyMs; const percentDiff = latencyDiff / lowestLatencyNode.latencyMs; if ( latencyDiff <= LATENCY_TOLERANCE_MS || percentDiff <= LATENCY_TOLERANCE_PERCENT ) { logger.info( `Sticking with previously connected node: ${previouslyConnectedNode.exitNodeName} ` + `(${previouslyConnectedNode.latencyMs} ms), latency diff = ${latencyDiff.toFixed(1)}ms ` + `/ ${(percentDiff * 100).toFixed(1)}%.` ); return previouslyConnectedNode; } } return lowestLatencyNode; } // Otherwise, find the next node (after the lowest) that has enough capacity for (let i = 1; i < sortedNodes.length; i++) { const node = sortedNodes[i]; if (node.weight >= MIN_CAPACITY_THRESHOLD) { logger.info( `Lowest latency node under capacity. Using next best: ${node.exitNodeName} ` + `(${node.latencyMs} ms, weight=${node.weight.toFixed(2)})` ); return node; } } // Fallback: pick the highest weight node const fallbackNode = validNodes.reduce((a, b) => a.weight > b.weight ? a : b ); logger.warn( `No nodes with ≥10% weight. Falling back to highest capacity node: ${fallbackNode.exitNodeName}` ); return fallbackNode; } export async function checkExitNodeOrg( exitNodeId: number, orgId: string, trx: Transaction | typeof db = db ) { const [exitNodeOrg] = await trx .select() .from(exitNodeOrgs) .where( and( eq(exitNodeOrgs.exitNodeId, exitNodeId), eq(exitNodeOrgs.orgId, orgId) ) ) .limit(1); if (!exitNodeOrg) { return true; } return false; } export async function resolveExitNodes(hostname: string, publicKey: string) { const resourceExitNodes = await db .select({ endpoint: exitNodes.endpoint, publicKey: exitNodes.publicKey, orgId: resources.orgId }) .from(resources) .innerJoin(targets, eq(resources.resourceId, targets.resourceId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) ) .innerJoin(sites, eq(targets.siteId, sites.siteId)) .innerJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId)) .where( and( eq(resources.fullDomain, hostname), ne(exitNodes.publicKey, publicKey), ne(targetHealthCheck.hcHealth, "unhealthy") ) ); return resourceExitNodes; } ================================================ FILE: server/private/lib/exitNodes/index.ts ================================================ /* * 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 "./exitNodeComms"; export * from "./exitNodes"; ================================================ FILE: server/private/lib/isLicencedOrSubscribed.ts ================================================ /* * 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 { build } from "@server/build"; import license from "#private/license/license"; import { isSubscribed } from "#private/lib/isSubscribed"; import { Tier } from "@server/types/Tiers"; export async function isLicensedOrSubscribed( orgId: string, tiers: Tier[] ): Promise { if (build === "enterprise") { return await license.isUnlocked(); } if (build === "saas") { return isSubscribed(orgId, tiers); } return false; } ================================================ FILE: server/private/lib/isSubscribed.ts ================================================ /* * 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 { build } from "@server/build"; import { getOrgTierData } from "#private/lib/billing"; import { Tier } from "@server/types/Tiers"; export async function isSubscribed( orgId: string, tiers: Tier[] ): Promise { if (build === "saas") { const { tier, active } = await getOrgTierData(orgId); const isTier = (tier && tiers.includes(tier)) || false; return active && isTier; } return false; } ================================================ FILE: server/private/lib/lock.ts ================================================ /* * 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 { config } from "@server/lib/config"; import logger from "@server/logger"; import { redis } from "#private/lib/redis"; import { v4 as uuidv4 } from "uuid"; const instanceId = uuidv4(); export class LockManager { /** * Acquire a distributed lock using Redis SET with NX and PX options * @param lockKey - Unique identifier for the lock * @param ttlMs - Time to live in milliseconds * @returns Promise - true if lock acquired, false otherwise */ async acquireLock( lockKey: string, ttlMs: number = 30000, maxRetries: number = 3, retryDelayMs: number = 100 ): Promise { if (!redis || !redis.status || redis.status !== "ready") { return true; } const lockValue = `${ instanceId }:${Date.now()}`; const redisKey = `lock:${lockKey}`; for (let attempt = 0; attempt < maxRetries; attempt++) { try { // Use SET with NX (only set if not exists) and PX (expire in milliseconds) // This is atomic and handles both setting and expiration const result = await redis.set( redisKey, lockValue, "PX", ttlMs, "NX" ); if (result === "OK") { logger.debug( `Lock acquired: ${lockKey} by ${ instanceId }` ); return true; } // Check if the existing lock is from this worker (reentrant behavior) const existingValue = await redis.get(redisKey); if ( existingValue && existingValue.startsWith( `${instanceId}:` ) ) { // Extend the lock TTL since it's the same worker await redis.pexpire(redisKey, ttlMs); logger.debug( `Lock extended: ${lockKey} by ${ instanceId }` ); return true; } // If this isn't our last attempt, wait before retrying with exponential backoff if (attempt < maxRetries - 1) { const delay = retryDelayMs * Math.pow(2, attempt); logger.debug( `Lock ${lockKey} not available, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})` ); await new Promise((resolve) => setTimeout(resolve, delay)); } } catch (error) { logger.error(`Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, error); // On error, still retry if we have attempts left if (attempt < maxRetries - 1) { const delay = retryDelayMs * Math.pow(2, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); } } } logger.debug( `Failed to acquire lock ${lockKey} after ${maxRetries} attempts` ); return false; } /** * Release a lock using Lua script to ensure atomicity * @param lockKey - Unique identifier for the lock */ async releaseLock(lockKey: string): Promise { if (!redis || !redis.status || redis.status !== "ready") { return; } const redisKey = `lock:${lockKey}`; // Lua script to ensure we only delete the lock if it belongs to this worker const luaScript = ` local key = KEYS[1] local worker_prefix = ARGV[1] local current_value = redis.call('GET', key) if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then return redis.call('DEL', key) else return 0 end `; try { const result = (await redis.eval( luaScript, 1, redisKey, `${instanceId}:` )) as number; if (result === 1) { logger.debug( `Lock released: ${lockKey} by ${ instanceId }` ); } else { logger.warn( `Lock not released - not owned by worker: ${lockKey} by ${ instanceId }` ); } } catch (error) { logger.error(`Failed to release lock ${lockKey}:`, error); } } /** * Force release a lock regardless of owner (use with caution) * @param lockKey - Unique identifier for the lock */ async forceReleaseLock(lockKey: string): Promise { if (!redis || !redis.status || redis.status !== "ready") { return; } const redisKey = `lock:${lockKey}`; try { const result = await redis.del(redisKey); if (result === 1) { logger.debug(`Lock force released: ${lockKey}`); } } catch (error) { logger.error(`Failed to force release lock ${lockKey}:`, error); } } /** * Check if a lock exists and get its info * @param lockKey - Unique identifier for the lock * @returns Promise<{exists: boolean, ownedByMe: boolean, ttl: number}> */ async getLockInfo(lockKey: string): Promise<{ exists: boolean; ownedByMe: boolean; ttl: number; owner?: string; }> { if (!redis || !redis.status || redis.status !== "ready") { return { exists: false, ownedByMe: true, ttl: 0 }; } const redisKey = `lock:${lockKey}`; try { const [value, ttl] = await Promise.all([ redis.get(redisKey), redis.pttl(redisKey) ]); const exists = value !== null; const ownedByMe = exists && value!.startsWith( `${instanceId}:` ); const owner = exists ? value!.split(":")[0] : undefined; return { exists, ownedByMe, ttl: ttl > 0 ? ttl : 0, owner }; } catch (error) { logger.error(`Failed to get lock info ${lockKey}:`, error); return { exists: false, ownedByMe: false, ttl: 0 }; } } /** * Extend the TTL of an existing lock owned by this worker * @param lockKey - Unique identifier for the lock * @param ttlMs - New TTL in milliseconds * @returns Promise - true if extended successfully */ async extendLock(lockKey: string, ttlMs: number): Promise { if (!redis || !redis.status || redis.status !== "ready") { return true; } const redisKey = `lock:${lockKey}`; // Lua script to extend TTL only if lock is owned by this worker const luaScript = ` local key = KEYS[1] local worker_prefix = ARGV[1] local ttl = tonumber(ARGV[2]) local current_value = redis.call('GET', key) if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then return redis.call('PEXPIRE', key, ttl) else return 0 end `; try { const result = (await redis.eval( luaScript, 1, redisKey, `${instanceId}:`, ttlMs.toString() )) as number; if (result === 1) { logger.debug( `Lock extended: ${lockKey} by ${ instanceId } for ${ttlMs}ms` ); return true; } return false; } catch (error) { logger.error(`Failed to extend lock ${lockKey}:`, error); return false; } } /** * Attempt to acquire lock with retries and exponential backoff * @param lockKey - Unique identifier for the lock * @param ttlMs - Time to live in milliseconds * @param maxRetries - Maximum number of retry attempts * @param baseDelayMs - Base delay between retries in milliseconds * @returns Promise - true if lock acquired */ async acquireLockWithRetry( lockKey: string, ttlMs: number = 30000, maxRetries: number = 5, baseDelayMs: number = 100 ): Promise { if (!redis || !redis.status || redis.status !== "ready") { return true; } for (let attempt = 0; attempt <= maxRetries; attempt++) { const acquired = await this.acquireLock(lockKey, ttlMs); if (acquired) { return true; } if (attempt < maxRetries) { // Exponential backoff with jitter const delay = baseDelayMs * Math.pow(2, attempt) + Math.random() * 100; await new Promise((resolve) => setTimeout(resolve, delay)); } } logger.warn( `Failed to acquire lock ${lockKey} after ${maxRetries + 1} attempts` ); return false; } /** * Execute a function while holding a lock * @param lockKey - Unique identifier for the lock * @param fn - Function to execute while holding the lock * @param ttlMs - Lock TTL in milliseconds * @returns Promise - Result of the executed function */ async withLock( lockKey: string, fn: () => Promise, ttlMs: number = 30000 ): Promise { if (!redis || !redis.status || redis.status !== "ready") { return await fn(); } const acquired = await this.acquireLock(lockKey, ttlMs); if (!acquired) { throw new Error(`Failed to acquire lock: ${lockKey}`); } try { return await fn(); } finally { await this.releaseLock(lockKey); } } /** * Clean up expired locks - Redis handles this automatically, but this method * can be used to get statistics about locks * @returns Promise<{activeLocksCount: number, locksOwnedByMe: number}> */ async getLockStatistics(): Promise<{ activeLocksCount: number; locksOwnedByMe: number; }> { if (!redis || !redis.status || redis.status !== "ready") { return { activeLocksCount: 0, locksOwnedByMe: 0 }; } try { const keys = await redis.keys("lock:*"); let locksOwnedByMe = 0; if (keys.length > 0) { const values = await redis.mget(...keys); locksOwnedByMe = values.filter( (value) => value && value.startsWith( `${instanceId}:` ) ).length; } return { activeLocksCount: keys.length, locksOwnedByMe }; } catch (error) { logger.error("Failed to get lock statistics:", error); return { activeLocksCount: 0, locksOwnedByMe: 0 }; } } /** * Close the Redis connection */ async disconnect(): Promise { if (!redis || !redis.status || redis.status !== "ready") { return; } await redis.quit(); } } export const lockManager = new LockManager(); ================================================ FILE: server/private/lib/logAccessAudit.ts ================================================ /* * 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 { accessAuditLog, logsDb, db, orgs } from "@server/db"; import { getCountryCodeForIp } from "@server/lib/geoip"; import logger from "@server/logger"; import { and, eq, lt } from "drizzle-orm"; import cache from "#private/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { stripPortFromHost } from "@server/lib/ip"; async function getAccessDays(orgId: string): Promise { // check cache first const cached = await cache.get(`org_${orgId}_accessDays`); if (cached !== undefined) { return cached; } const [org] = await db .select({ settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction }) .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (!org) { return 0; } // store the result in cache await cache.set( `org_${orgId}_accessDays`, org.settingsLogRetentionDaysAction, 300 ); return org.settingsLogRetentionDaysAction; } export async function cleanUpOldLogs(orgId: string, retentionDays: number) { const cutoffTimestamp = calculateCutoffTimestamp(retentionDays); try { await logsDb .delete(accessAuditLog) .where( and( lt(accessAuditLog.timestamp, cutoffTimestamp), eq(accessAuditLog.orgId, orgId) ) ); logger.debug( `Cleaned up access audit logs older than ${retentionDays} days` ); } catch (error) { logger.error("Error cleaning up old action audit logs:", error); } } export async function logAccessAudit(data: { action: boolean; type: string; orgId: string; resourceId?: number; user?: { username: string; userId: string }; apiKey?: { name: string | null; apiKeyId: string }; metadata?: any; userAgent?: string; requestIp?: string; }) { try { const retentionDays = await getAccessDays(data.orgId); if (retentionDays === 0) { // do not log return; } let actorType: string | undefined; let actor: string | undefined; let actorId: string | undefined; const user = data.user; if (user) { actorType = "user"; actor = user.username; actorId = user.userId; } const apiKey = data.apiKey; if (apiKey) { actorType = "apiKey"; actor = apiKey.name || apiKey.apiKeyId; actorId = apiKey.apiKeyId; } // if (!actorType || !actor || !actorId) { // logger.warn("logRequestAudit: Incomplete actor information"); // return; // } const timestamp = Math.floor(Date.now() / 1000); let metadata = null; if (metadata) { metadata = JSON.stringify(metadata); } const clientIp = data.requestIp ? stripPortFromHost(data.requestIp) : undefined; const countryCode = data.requestIp ? await getCountryCodeFromIp(data.requestIp) : undefined; await logsDb.insert(accessAuditLog).values({ timestamp: timestamp, orgId: data.orgId, actorType, actor, actorId, action: data.action, type: data.type, metadata, resourceId: data.resourceId, userAgent: data.userAgent, ip: clientIp, location: countryCode }); } catch (error) { logger.error(error); } } async function getCountryCodeFromIp(ip: string): Promise { const geoIpCacheKey = `geoip_access:${ip}`; let cachedCountryCode: string | undefined = await cache.get(geoIpCacheKey); if (!cachedCountryCode) { cachedCountryCode = await getCountryCodeForIp(ip); // do it locally // Only cache successful lookups to avoid filling cache with undefined values if (cachedCountryCode) { // Cache for longer since IP geolocation doesn't change frequently await cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes } } return cachedCountryCode; } ================================================ FILE: server/private/lib/rateLimit.test.ts ================================================ /* * 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. */ // Simple test file for the rate limit service with Redis // Run with: npx ts-node rateLimitService.test.ts import { RateLimitService } from "./rateLimit"; function generateClientId() { return "client-" + Math.random().toString(36).substring(2, 15); } async function runTests() { console.log("Starting Rate Limit Service Tests...\n"); const rateLimitService = new RateLimitService(); let testsPassed = 0; let testsTotal = 0; // Helper function to run a test async function test(name: string, testFn: () => Promise) { testsTotal++; try { await testFn(); console.log(`✅ ${name}`); testsPassed++; } catch (error) { console.log(`❌ ${name}: ${error}`); } } // Helper function for assertions function assert(condition: boolean, message: string) { if (!condition) { throw new Error(message); } } // Test 1: Basic rate limiting await test("Should allow requests under the limit", async () => { const clientId = generateClientId(); const maxRequests = 5; for (let i = 0; i < maxRequests - 1; i++) { const result = await rateLimitService.checkRateLimit( clientId, undefined, maxRequests ); assert(!result.isLimited, `Request ${i + 1} should be allowed`); assert( result.totalHits === i + 1, `Expected ${i + 1} hits, got ${result.totalHits}` ); } }); // Test 2: Rate limit blocking await test("Should block requests over the limit", async () => { const clientId = generateClientId(); const maxRequests = 30; // Use up all allowed requests for (let i = 0; i < maxRequests - 1; i++) { const result = await rateLimitService.checkRateLimit( clientId, undefined, maxRequests ); assert(!result.isLimited, `Request ${i + 1} should be allowed`); } // Next request should be blocked const blockedResult = await rateLimitService.checkRateLimit( clientId, undefined, maxRequests ); assert(blockedResult.isLimited, "Request should be blocked"); assert( blockedResult.reason === "global", "Should be blocked for global reason" ); }); // Test 3: Message type limits await test("Should handle message type limits", async () => { const clientId = generateClientId(); const globalMax = 10; const messageTypeMax = 2; // Send messages of type 'ping' up to the limit for (let i = 0; i < messageTypeMax - 1; i++) { const result = await rateLimitService.checkRateLimit( clientId, "ping", globalMax, messageTypeMax ); assert( !result.isLimited, `Ping message ${i + 1} should be allowed` ); } // Next 'ping' should be blocked const blockedResult = await rateLimitService.checkRateLimit( clientId, "ping", globalMax, messageTypeMax ); assert(blockedResult.isLimited, "Ping message should be blocked"); assert( blockedResult.reason === "message_type:ping", "Should be blocked for message type" ); // Other message types should still work const otherResult = await rateLimitService.checkRateLimit( clientId, "pong", globalMax, messageTypeMax ); assert(!otherResult.isLimited, "Pong message should be allowed"); }); // Test 4: Reset functionality await test("Should reset client correctly", async () => { const clientId = generateClientId(); const maxRequests = 3; // Use up some requests await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); await rateLimitService.checkRateLimit(clientId, "test", maxRequests); // Reset the client await rateLimitService.resetKey(clientId); // Should be able to make fresh requests const result = await rateLimitService.checkRateLimit( clientId, undefined, maxRequests ); assert(!result.isLimited, "Request after reset should be allowed"); assert(result.totalHits === 1, "Should have 1 hit after reset"); }); // Test 5: Different clients are independent await test("Should handle different clients independently", async () => { const client1 = generateClientId(); const client2 = generateClientId(); const maxRequests = 2; // Client 1 uses up their limit await rateLimitService.checkRateLimit(client1, undefined, maxRequests); await rateLimitService.checkRateLimit(client1, undefined, maxRequests); const client1Blocked = await rateLimitService.checkRateLimit( client1, undefined, maxRequests ); assert(client1Blocked.isLimited, "Client 1 should be blocked"); // Client 2 should still be able to make requests const client2Result = await rateLimitService.checkRateLimit( client2, undefined, maxRequests ); assert(!client2Result.isLimited, "Client 2 should not be blocked"); assert(client2Result.totalHits === 1, "Client 2 should have 1 hit"); }); // Test 6: Decrement functionality await test("Should decrement correctly", async () => { const clientId = generateClientId(); const maxRequests = 5; // Make some requests await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); await rateLimitService.checkRateLimit(clientId, undefined, maxRequests); let result = await rateLimitService.checkRateLimit( clientId, undefined, maxRequests ); assert(result.totalHits === 3, "Should have 3 hits before decrement"); // Decrement await rateLimitService.decrementRateLimit(clientId); // Next request should reflect the decrement result = await rateLimitService.checkRateLimit( clientId, undefined, maxRequests ); assert( result.totalHits === 3, "Should have 3 hits after decrement + increment" ); }); // Wait a moment for any pending Redis operations console.log("\nWaiting for Redis sync..."); await new Promise((resolve) => setTimeout(resolve, 1000)); // Force sync to test Redis integration await test("Should sync to Redis", async () => { await rateLimitService.forceSyncAllPendingData(); // If this doesn't throw, Redis sync is working assert(true, "Redis sync completed"); }); // Cleanup await rateLimitService.cleanup(); // Results console.log(`\n--- Test Results ---`); console.log(`✅ Passed: ${testsPassed}/${testsTotal}`); console.log(`❌ Failed: ${testsTotal - testsPassed}/${testsTotal}`); if (testsPassed === testsTotal) { console.log("\n🎉 All tests passed!"); process.exit(0); } else { console.log("\n💥 Some tests failed!"); process.exit(1); } } // Run the tests runTests().catch((error) => { console.error("Test runner error:", error); process.exit(1); }); ================================================ FILE: server/private/lib/rateLimit.ts ================================================ /* * 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 logger from "@server/logger"; import redisManager from "#private/lib/redis"; import { build } from "@server/build"; // Rate limiting configuration export const RATE_LIMIT_WINDOW = 60; // 1 minute in seconds export const RATE_LIMIT_MAX_REQUESTS = 100; export const RATE_LIMIT_PER_MESSAGE_TYPE = 20; // Per message type limit within the window // Configuration for batched Redis sync export const REDIS_SYNC_THRESHOLD = 15; // Sync to Redis every N messages export const REDIS_SYNC_FORCE_INTERVAL = 30000; // Force sync every 30 seconds as backup interface RateLimitTracker { count: number; windowStart: number; pendingCount: number; lastSyncedCount: number; } interface RateLimitResult { isLimited: boolean; reason?: string; totalHits?: number; resetTime?: Date; } export class RateLimitService { private localRateLimitTracker: Map = new Map(); private localMessageTypeRateLimitTracker: Map = new Map(); private cleanupInterval: NodeJS.Timeout | null = null; private forceSyncInterval: NodeJS.Timeout | null = null; constructor() { if (build == "oss") { return; } // Start cleanup and sync intervals this.cleanupInterval = setInterval(() => { this.cleanupLocalRateLimit().catch((error) => { logger.error("Error during rate limit cleanup:", error); }); }, 60000); // Run cleanup every minute this.forceSyncInterval = setInterval(() => { this.forceSyncAllPendingData().catch((error) => { logger.error("Error during force sync:", error); }); }, REDIS_SYNC_FORCE_INTERVAL); } // Redis keys private getRateLimitKey(clientId: string): string { return `ratelimit:${clientId}`; } private getMessageTypeRateLimitKey( clientId: string, messageType: string ): string { return `ratelimit:${clientId}:${messageType}`; } // Helper function to clean up old timestamp fields from a Redis hash private async cleanupOldTimestamps( key: string, windowStart: number ): Promise { if (!redisManager.isRedisEnabled()) return; try { const client = redisManager.getClient(); if (!client) return; // Get all fields in the hash const allData = await redisManager.hgetall(key); if (!allData || Object.keys(allData).length === 0) return; // Find fields that are older than the window const fieldsToDelete: string[] = []; for (const timestamp of Object.keys(allData)) { const time = parseInt(timestamp); if (time < windowStart) { fieldsToDelete.push(timestamp); } } // Delete old fields in batches to avoid call stack size exceeded errors // The spread operator can cause issues with very large arrays if (fieldsToDelete.length > 0) { const batchSize = 1000; // Process 1000 fields at a time for (let i = 0; i < fieldsToDelete.length; i += batchSize) { const batch = fieldsToDelete.slice(i, i + batchSize); await client.hdel(key, ...batch); } logger.debug( `Cleaned up ${fieldsToDelete.length} old timestamp fields from ${key}` ); } } catch (error) { logger.error( `Failed to cleanup old timestamps for key ${key}:`, error ); // Don't throw - cleanup failures shouldn't block rate limiting } } // Helper function to sync local rate limit data to Redis private async syncRateLimitToRedis( clientId: string, tracker: RateLimitTracker ): Promise { if (!redisManager.isRedisEnabled() || tracker.pendingCount === 0) return; try { const currentTime = Math.floor(Date.now() / 1000); const windowStart = currentTime - RATE_LIMIT_WINDOW; const globalKey = this.getRateLimitKey(clientId); // Clean up old timestamp fields before writing await this.cleanupOldTimestamps(globalKey, windowStart); // Get current value and add pending count const currentValue = await redisManager.hget( globalKey, currentTime.toString() ); const newValue = ( parseInt(currentValue || "0") + tracker.pendingCount ).toString(); await redisManager.hset( globalKey, currentTime.toString(), newValue ); // Set TTL using the client directly - this prevents the key from persisting forever if (redisManager.getClient()) { await redisManager .getClient() .expire(globalKey, RATE_LIMIT_WINDOW + 10); } // Update tracking tracker.lastSyncedCount = tracker.count; tracker.pendingCount = 0; logger.debug( `Synced global rate limit to Redis for client ${clientId}` ); } catch (error) { logger.error("Failed to sync global rate limit to Redis:", error); } } private async syncMessageTypeRateLimitToRedis( clientId: string, messageType: string, tracker: RateLimitTracker ): Promise { if (!redisManager.isRedisEnabled() || tracker.pendingCount === 0) return; try { const currentTime = Math.floor(Date.now() / 1000); const windowStart = currentTime - RATE_LIMIT_WINDOW; const messageTypeKey = this.getMessageTypeRateLimitKey( clientId, messageType ); // Clean up old timestamp fields before writing await this.cleanupOldTimestamps(messageTypeKey, windowStart); // Get current value and add pending count const currentValue = await redisManager.hget( messageTypeKey, currentTime.toString() ); const newValue = ( parseInt(currentValue || "0") + tracker.pendingCount ).toString(); await redisManager.hset( messageTypeKey, currentTime.toString(), newValue ); // Set TTL using the client directly - this prevents the key from persisting forever if (redisManager.getClient()) { await redisManager .getClient() .expire(messageTypeKey, RATE_LIMIT_WINDOW + 10); } // Update tracking tracker.lastSyncedCount = tracker.count; tracker.pendingCount = 0; logger.debug( `Synced message type rate limit to Redis for client ${clientId}, type ${messageType}` ); } catch (error) { logger.error( "Failed to sync message type rate limit to Redis:", error ); } } // Initialize local tracker from Redis data private async initializeLocalTracker( clientId: string ): Promise { const currentTime = Math.floor(Date.now() / 1000); const windowStart = currentTime - RATE_LIMIT_WINDOW; if (!redisManager.isRedisEnabled()) { return { count: 0, windowStart: currentTime, pendingCount: 0, lastSyncedCount: 0 }; } try { const globalKey = this.getRateLimitKey(clientId); // Clean up old timestamp fields before reading await this.cleanupOldTimestamps(globalKey, windowStart); const globalRateLimitData = await redisManager.hgetall(globalKey); let count = 0; for (const [timestamp, countStr] of Object.entries( globalRateLimitData )) { const time = parseInt(timestamp); if (time >= windowStart) { count += parseInt(countStr); } } return { count, windowStart: currentTime, pendingCount: 0, lastSyncedCount: count }; } catch (error) { logger.error( "Failed to initialize global tracker from Redis:", error ); return { count: 0, windowStart: currentTime, pendingCount: 0, lastSyncedCount: 0 }; } } private async initializeMessageTypeTracker( clientId: string, messageType: string ): Promise { const currentTime = Math.floor(Date.now() / 1000); const windowStart = currentTime - RATE_LIMIT_WINDOW; if (!redisManager.isRedisEnabled()) { return { count: 0, windowStart: currentTime, pendingCount: 0, lastSyncedCount: 0 }; } try { const messageTypeKey = this.getMessageTypeRateLimitKey( clientId, messageType ); // Clean up old timestamp fields before reading await this.cleanupOldTimestamps(messageTypeKey, windowStart); const messageTypeRateLimitData = await redisManager.hgetall(messageTypeKey); let count = 0; for (const [timestamp, countStr] of Object.entries( messageTypeRateLimitData )) { const time = parseInt(timestamp); if (time >= windowStart) { count += parseInt(countStr); } } return { count, windowStart: currentTime, pendingCount: 0, lastSyncedCount: count }; } catch (error) { logger.error( "Failed to initialize message type tracker from Redis:", error ); return { count: 0, windowStart: currentTime, pendingCount: 0, lastSyncedCount: 0 }; } } // Main rate limiting function async checkRateLimit( clientId: string, messageType?: string, maxRequests: number = RATE_LIMIT_MAX_REQUESTS, messageTypeLimit: number = RATE_LIMIT_PER_MESSAGE_TYPE, windowMs: number = RATE_LIMIT_WINDOW * 1000 ): Promise { const currentTime = Math.floor(Date.now() / 1000); const windowStart = currentTime - Math.floor(windowMs / 1000); // Check global rate limit let globalTracker = this.localRateLimitTracker.get(clientId); if (!globalTracker || globalTracker.windowStart < windowStart) { // New window or first request - initialize from Redis if available globalTracker = await this.initializeLocalTracker(clientId); globalTracker.windowStart = currentTime; this.localRateLimitTracker.set(clientId, globalTracker); } // Increment global counters globalTracker.count++; globalTracker.pendingCount++; this.localRateLimitTracker.set(clientId, globalTracker); // Check if global limit would be exceeded if (globalTracker.count >= maxRequests) { return { isLimited: true, reason: "global", totalHits: globalTracker.count, resetTime: new Date( (globalTracker.windowStart + Math.floor(windowMs / 1000)) * 1000 ) }; } // Sync to Redis if threshold reached if (globalTracker.pendingCount >= REDIS_SYNC_THRESHOLD) { this.syncRateLimitToRedis(clientId, globalTracker); } // Check message type specific rate limit if messageType is provided if (messageType) { const messageTypeKey = `${clientId}:${messageType}`; let messageTypeTracker = this.localMessageTypeRateLimitTracker.get(messageTypeKey); if ( !messageTypeTracker || messageTypeTracker.windowStart < windowStart ) { // New window or first request for this message type - initialize from Redis if available messageTypeTracker = await this.initializeMessageTypeTracker( clientId, messageType ); messageTypeTracker.windowStart = currentTime; this.localMessageTypeRateLimitTracker.set( messageTypeKey, messageTypeTracker ); } // Increment message type counters messageTypeTracker.count++; messageTypeTracker.pendingCount++; this.localMessageTypeRateLimitTracker.set( messageTypeKey, messageTypeTracker ); // Check if message type limit would be exceeded if (messageTypeTracker.count >= messageTypeLimit) { return { isLimited: true, reason: `message_type:${messageType}`, totalHits: messageTypeTracker.count, resetTime: new Date( (messageTypeTracker.windowStart + Math.floor(windowMs / 1000)) * 1000 ) }; } // Sync to Redis if threshold reached if (messageTypeTracker.pendingCount >= REDIS_SYNC_THRESHOLD) { this.syncMessageTypeRateLimitToRedis( clientId, messageType, messageTypeTracker ); } } return { isLimited: false, totalHits: globalTracker.count, resetTime: new Date( (globalTracker.windowStart + Math.floor(windowMs / 1000)) * 1000 ) }; } // Decrement function for skipSuccessfulRequests/skipFailedRequests functionality async decrementRateLimit( clientId: string, messageType?: string ): Promise { // Decrement global counter const globalTracker = this.localRateLimitTracker.get(clientId); if (globalTracker && globalTracker.count > 0) { globalTracker.count--; // We need to account for this in pending count to sync correctly globalTracker.pendingCount--; } // Decrement message type counter if provided if (messageType) { const messageTypeKey = `${clientId}:${messageType}`; const messageTypeTracker = this.localMessageTypeRateLimitTracker.get(messageTypeKey); if (messageTypeTracker && messageTypeTracker.count > 0) { messageTypeTracker.count--; messageTypeTracker.pendingCount--; } } } // Reset key function async resetKey(clientId: string): Promise { // Remove from local tracking this.localRateLimitTracker.delete(clientId); // Remove all message type entries for this client for (const [key] of this.localMessageTypeRateLimitTracker) { if (key.startsWith(`${clientId}:`)) { this.localMessageTypeRateLimitTracker.delete(key); } } // Remove from Redis if enabled if (redisManager.isRedisEnabled()) { const globalKey = this.getRateLimitKey(clientId); await redisManager.del(globalKey); // Get all message type keys for this client and delete them const client = redisManager.getClient(); if (client) { const messageTypeKeys = await client.keys( `ratelimit:${clientId}:*` ); if (messageTypeKeys.length > 0) { await Promise.all( messageTypeKeys.map((key) => redisManager.del(key)) ); } } } } // Cleanup old local rate limit entries and force sync pending data private async cleanupLocalRateLimit(): Promise { const currentTime = Math.floor(Date.now() / 1000); const windowStart = currentTime - RATE_LIMIT_WINDOW; // Clean up global rate limit tracking and sync pending data for (const [ clientId, tracker ] of this.localRateLimitTracker.entries()) { if (tracker.windowStart < windowStart) { // Sync any pending data before cleanup if (tracker.pendingCount > 0) { await this.syncRateLimitToRedis(clientId, tracker); } this.localRateLimitTracker.delete(clientId); } } // Clean up message type rate limit tracking and sync pending data for (const [ key, tracker ] of this.localMessageTypeRateLimitTracker.entries()) { if (tracker.windowStart < windowStart) { // Sync any pending data before cleanup if (tracker.pendingCount > 0) { const [clientId, messageType] = key.split(":", 2); await this.syncMessageTypeRateLimitToRedis( clientId, messageType, tracker ); } this.localMessageTypeRateLimitTracker.delete(key); } } } // Force sync all pending rate limit data to Redis async forceSyncAllPendingData(): Promise { if (!redisManager.isRedisEnabled()) return; logger.debug("Force syncing all pending rate limit data to Redis..."); // Sync all pending global rate limits for (const [ clientId, tracker ] of this.localRateLimitTracker.entries()) { if (tracker.pendingCount > 0) { await this.syncRateLimitToRedis(clientId, tracker); } } // Sync all pending message type rate limits for (const [ key, tracker ] of this.localMessageTypeRateLimitTracker.entries()) { if (tracker.pendingCount > 0) { const [clientId, messageType] = key.split(":", 2); await this.syncMessageTypeRateLimitToRedis( clientId, messageType, tracker ); } } logger.debug("Completed force sync of pending rate limit data"); } // Cleanup function for graceful shutdown async cleanup(): Promise { if (build == "oss") { return; } // Clear intervals if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } if (this.forceSyncInterval) { clearInterval(this.forceSyncInterval); } // Force sync all pending data await this.forceSyncAllPendingData(); // Clear local data this.localRateLimitTracker.clear(); this.localMessageTypeRateLimitTracker.clear(); logger.info("Rate limit service cleanup completed"); } } // Export singleton instance export const rateLimitService = new RateLimitService(); ================================================ FILE: server/private/lib/rateLimitStore.ts ================================================ /* * 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 { build } from "@server/build"; import privateConfig from "#private/lib/config"; import { MemoryStore, Store } from "express-rate-limit"; import RedisStore from "#private/lib/redisStore"; export function createStore(): Store { if ( build != "oss" && privateConfig.getRawPrivateConfig().flags.enable_redis ) { const rateLimitStore: Store = new RedisStore({ prefix: "api-rate-limit", // Optional: customize Redis key prefix skipFailedRequests: true, // Don't count failed requests skipSuccessfulRequests: false // Count successful requests }); return rateLimitStore; } else { const rateLimitStore: Store = new MemoryStore(); return rateLimitStore; } } ================================================ FILE: server/private/lib/readConfigFile.ts ================================================ /* * 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 fs from "fs"; import yaml from "js-yaml"; import { privateConfigFilePath1 } from "@server/lib/consts"; import { z } from "zod"; import { colorsSchema } from "@server/lib/colorsSchema"; import { build } from "@server/build"; import { getEnvOrYaml } from "@server/lib/getEnvOrYaml"; const portSchema = z.number().positive().gt(0).lte(65535); export const privateConfigSchema = z.object({ app: z .object({ region: z.string().optional().default("default"), base_domain: z.string().optional(), identity_provider_mode: z.enum(["global", "org"]).optional() }) .optional() .default({ region: "default" }), server: z .object({ encryption_key: z .string() .optional() .transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")), reo_client_id: z .string() .optional() .transform(getEnvOrYaml("REO_CLIENT_ID")), fossorial_api: z .string() .optional() .default("https://api.fossorial.io"), fossorial_api_key: z .string() .optional() .transform(getEnvOrYaml("FOSSORIAL_API_KEY")) }) .optional() .prefault({}), redis: z .object({ host: z.string(), port: portSchema, password: z.string().optional(), db: z.int().nonnegative().optional().default(0), replicas: z .array( z.object({ host: z.string(), port: portSchema, password: z.string().optional(), db: z.int().nonnegative().optional().default(0) }) ) .optional(), tls: z .object({ rejectUnauthorized: z .boolean() .optional() .default(true) }) .optional() }) .optional(), gerbil: z .object({ local_exit_node_reachable_at: z .string() .optional() .default("http://gerbil:3004") }) .optional() .prefault({}), flags: z .object({ enable_redis: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false), use_org_only_idp: z.boolean().optional() }) .optional() .prefault({}), branding: z .object({ app_name: z.string().optional(), background_image_path: z.string().optional(), colors: z .object({ light: colorsSchema.optional(), dark: colorsSchema.optional() }) .optional(), logo: z .object({ light_path: z.string().optional(), dark_path: z.string().optional(), auth_page: z .object({ width: z.number().optional(), height: z.number().optional() }) .optional(), navbar: z .object({ width: z.number().optional(), height: z.number().optional() }) .optional() }) .optional(), footer: z .array( z.object({ text: z.string(), href: z.string().optional() }) ) .optional(), hide_auth_layout_footer: z.boolean().optional().default(false), login_page: z .object({ subtitle_text: z.string().optional() }) .optional(), signup_page: z .object({ subtitle_text: z.string().optional() }) .optional(), resource_auth_page: z .object({ show_logo: z.boolean().optional(), hide_powered_by: z.boolean().optional(), title_text: z.string().optional(), subtitle_text: z.string().optional() }) .optional(), emails: z .object({ signature: z.string().optional(), colors: z .object({ primary: z.string().optional() }) .optional() }) .optional() }) .optional(), stripe: z .object({ secret_key: z .string() .optional() .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), webhook_secret: z .string() .optional() .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")), // s3Bucket: z.string(), // s3Region: z.string().default("us-east-1"), // localFilePath: z.string().optional() }) .optional() }) .transform((data) => { // this to maintain backwards compatibility with the old config file const identityProviderMode = data.app?.identity_provider_mode; const useOrgOnlyIdp = data.flags?.use_org_only_idp; if (identityProviderMode !== undefined) { return data; } if (useOrgOnlyIdp === true) { return { ...data, app: { ...data.app, identity_provider_mode: "org" as const } }; } if (useOrgOnlyIdp === false) { return { ...data, app: { ...data.app, identity_provider_mode: "global" as const } }; } return data; }); export function readPrivateConfigFile() { if (build == "oss") { return {}; } // test if the config file is there if (!fs.existsSync(privateConfigFilePath1)) { // console.warn( // `Private configuration file not found at ${privateConfigFilePath1}. Using default configuration.` // ); // load the default values of the zod schema and return those return privateConfigSchema.parse({}); } const loadConfig = (configPath: string) => { try { const yamlContent = fs.readFileSync(configPath, "utf8"); if (yamlContent.trim() === "") { return {}; } const config = yaml.load(yamlContent); return config; } catch (error) { if (error instanceof Error) { throw new Error( `Error loading configuration file: ${error.message}` ); } throw error; } }; let environment: any = {}; if (fs.existsSync(privateConfigFilePath1)) { environment = loadConfig(privateConfigFilePath1); } if (!environment) { throw new Error("No private configuration file found."); } return environment; } ================================================ FILE: server/private/lib/redis.ts ================================================ /* * 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 Redis, { RedisOptions } from "ioredis"; import logger from "@server/logger"; import privateConfig from "#private/lib/config"; import { build } from "@server/build"; class RedisManager { public client: Redis | null = null; private writeClient: Redis | null = null; // Master for writes private readClient: Redis | null = null; // Replica for reads private subscriber: Redis | null = null; private publisher: Redis | null = null; private isEnabled: boolean = false; private isHealthy: boolean = true; private isWriteHealthy: boolean = true; private isReadHealthy: boolean = true; private lastHealthCheck: number = 0; private healthCheckInterval: number = 30000; // 30 seconds private connectionTimeout: number = 15000; // 15 seconds private commandTimeout: number = 15000; // 15 seconds private hasReplicas: boolean = false; private maxRetries: number = 3; private baseRetryDelay: number = 100; // 100ms private maxRetryDelay: number = 2000; // 2 seconds private backoffMultiplier: number = 2; private subscribers: Map< string, Set<(channel: string, message: string) => void> > = new Map(); private reconnectionCallbacks: Set<() => Promise> = new Set(); constructor() { if (build == "oss") { this.isEnabled = false; return; } this.isEnabled = privateConfig.getRawPrivateConfig().flags.enable_redis || false; if (this.isEnabled) { this.initializeClients(); } } // Register callback to be called when Redis reconnects public onReconnection(callback: () => Promise): void { this.reconnectionCallbacks.add(callback); } // Unregister reconnection callback public offReconnection(callback: () => Promise): void { this.reconnectionCallbacks.delete(callback); } private async triggerReconnectionCallbacks(): Promise { logger.info( `Triggering ${this.reconnectionCallbacks.size} reconnection callbacks` ); const promises = Array.from(this.reconnectionCallbacks).map( async (callback) => { try { await callback(); } catch (error) { logger.error("Error in reconnection callback:", error); } } ); await Promise.allSettled(promises); } private async resubscribeToChannels(): Promise { if (!this.subscriber || this.subscribers.size === 0) return; logger.info( `Re-subscribing to ${this.subscribers.size} channels after Redis reconnection` ); try { const channels = Array.from(this.subscribers.keys()); if (channels.length > 0) { await this.subscriber.subscribe(...channels); logger.info( `Successfully re-subscribed to channels: ${channels.join(", ")}` ); } } catch (error) { logger.error("Failed to re-subscribe to channels:", error); } } private getRedisConfig(): RedisOptions { const redisConfig = privateConfig.getRawPrivateConfig().redis!; const opts: RedisOptions = { host: redisConfig.host!, port: redisConfig.port!, password: redisConfig.password, db: redisConfig.db }; // Enable TLS if configured (required for AWS ElastiCache in-transit encryption) if (redisConfig.tls) { opts.tls = { rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true }; } return opts; } private getReplicaRedisConfig(): RedisOptions | null { const redisConfig = privateConfig.getRawPrivateConfig().redis!; if (!redisConfig.replicas || redisConfig.replicas.length === 0) { return null; } // Use the first replica for simplicity // In production, you might want to implement load balancing across replicas const replica = redisConfig.replicas[0]; const opts: RedisOptions = { host: replica.host!, port: replica.port!, password: replica.password, db: replica.db || redisConfig.db }; // Enable TLS if configured (required for AWS ElastiCache in-transit encryption) if (redisConfig.tls) { opts.tls = { rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true }; } return opts; } // Add reconnection logic in initializeClients private initializeClients(): void { const masterConfig = this.getRedisConfig(); const replicaConfig = this.getReplicaRedisConfig(); this.hasReplicas = replicaConfig !== null; try { // Initialize master connection for writes this.writeClient = new Redis({ ...masterConfig, enableReadyCheck: false, maxRetriesPerRequest: 3, keepAlive: 30000, connectTimeout: this.connectionTimeout, commandTimeout: this.commandTimeout }); // Initialize replica connection for reads (if available) if (this.hasReplicas) { this.readClient = new Redis({ ...replicaConfig!, enableReadyCheck: false, maxRetriesPerRequest: 3, keepAlive: 30000, connectTimeout: this.connectionTimeout, commandTimeout: this.commandTimeout }); } else { // Fallback to master for reads if no replicas this.readClient = this.writeClient; } // Backward compatibility - point to write client this.client = this.writeClient; // Publisher uses master (writes) this.publisher = new Redis({ ...masterConfig, enableReadyCheck: false, maxRetriesPerRequest: 3, keepAlive: 30000, connectTimeout: this.connectionTimeout, commandTimeout: this.commandTimeout }); // Subscriber uses replica if available (reads) this.subscriber = new Redis({ ...(this.hasReplicas ? replicaConfig! : masterConfig), enableReadyCheck: false, maxRetriesPerRequest: 3, keepAlive: 30000, connectTimeout: this.connectionTimeout, commandTimeout: this.commandTimeout }); // Add reconnection handlers for write client this.writeClient.on("error", (err) => { logger.error("Redis write client error:", err); this.isWriteHealthy = false; this.isHealthy = false; }); this.writeClient.on("reconnecting", () => { logger.info("Redis write client reconnecting..."); this.isWriteHealthy = false; this.isHealthy = false; }); this.writeClient.on("ready", () => { logger.info("Redis write client ready"); this.isWriteHealthy = true; this.updateOverallHealth(); // Trigger reconnection callbacks when Redis comes back online if (this.isHealthy) { this.triggerReconnectionCallbacks().catch((error) => { logger.error( "Error triggering reconnection callbacks:", error ); }); } }); this.writeClient.on("connect", () => { logger.info("Redis write client connected"); }); // Add reconnection handlers for read client (if different from write) if (this.hasReplicas && this.readClient !== this.writeClient) { this.readClient.on("error", (err) => { logger.error("Redis read client error:", err); this.isReadHealthy = false; this.updateOverallHealth(); }); this.readClient.on("reconnecting", () => { logger.info("Redis read client reconnecting..."); this.isReadHealthy = false; this.updateOverallHealth(); }); this.readClient.on("ready", () => { logger.info("Redis read client ready"); this.isReadHealthy = true; this.updateOverallHealth(); // Trigger reconnection callbacks when Redis comes back online if (this.isHealthy) { this.triggerReconnectionCallbacks().catch((error) => { logger.error( "Error triggering reconnection callbacks:", error ); }); } }); this.readClient.on("connect", () => { logger.info("Redis read client connected"); }); } else { // If using same client for reads and writes this.isReadHealthy = this.isWriteHealthy; } this.publisher.on("error", (err) => { logger.error("Redis publisher error:", err); }); this.publisher.on("ready", () => { logger.info("Redis publisher ready"); }); this.publisher.on("connect", () => { logger.info("Redis publisher connected"); }); this.subscriber.on("error", (err) => { logger.error("Redis subscriber error:", err); }); this.subscriber.on("ready", () => { logger.info("Redis subscriber ready"); // Re-subscribe to all channels after reconnection this.resubscribeToChannels().catch((error: any) => { logger.error("Error re-subscribing to channels:", error); }); }); this.subscriber.on("connect", () => { logger.info("Redis subscriber connected"); }); // Set up message handler for subscriber this.subscriber.on( "message", (channel: string, message: string) => { const channelSubscribers = this.subscribers.get(channel); if (channelSubscribers) { channelSubscribers.forEach((callback) => { try { callback(channel, message); } catch (error) { logger.error( `Error in subscriber callback for channel ${channel}:`, error ); } }); } } ); const setupMessage = this.hasReplicas ? "Redis clients initialized successfully with replica support" : "Redis clients initialized successfully (single instance)"; logger.info(setupMessage); // Start periodic health monitoring this.startHealthMonitoring(); } catch (error) { logger.error("Failed to initialize Redis clients:", error); this.isEnabled = false; } } private updateOverallHealth(): void { // Overall health is true if write is healthy and (read is healthy OR we don't have replicas) this.isHealthy = this.isWriteHealthy && (this.isReadHealthy || !this.hasReplicas); } private async executeWithRetry( operation: () => Promise, operationName: string, fallbackOperation?: () => Promise ): Promise { let lastError: Error | null = null; for (let attempt = 0; attempt <= this.maxRetries; attempt++) { try { return await operation(); } catch (error) { lastError = error as Error; // If this is the last attempt, try fallback if available if (attempt === this.maxRetries && fallbackOperation) { try { logger.warn( `${operationName} primary operation failed, trying fallback` ); return await fallbackOperation(); } catch (fallbackError) { logger.error( `${operationName} fallback also failed:`, fallbackError ); throw lastError; } } // Don't retry on the last attempt if (attempt === this.maxRetries) { break; } // Calculate delay with exponential backoff const delay = Math.min( this.baseRetryDelay * Math.pow(this.backoffMultiplier, attempt), this.maxRetryDelay ); logger.warn( `${operationName} failed (attempt ${attempt + 1}/${this.maxRetries + 1}), retrying in ${delay}ms:`, error ); // Wait before retrying await new Promise((resolve) => setTimeout(resolve, delay)); } } logger.error( `${operationName} failed after ${this.maxRetries + 1} attempts:`, lastError ); throw lastError; } private startHealthMonitoring(): void { if (!this.isEnabled) return; // Check health every 30 seconds setInterval(async () => { try { await this.checkRedisHealth(); } catch (error) { logger.error("Error during Redis health monitoring:", error); } }, this.healthCheckInterval); } public isRedisEnabled(): boolean { return this.isEnabled && this.client !== null && this.isHealthy; } private async checkRedisHealth(): Promise { const now = Date.now(); // Only check health every 30 seconds if (now - this.lastHealthCheck < this.healthCheckInterval) { return this.isHealthy; } this.lastHealthCheck = now; if (!this.writeClient) { this.isHealthy = false; this.isWriteHealthy = false; this.isReadHealthy = false; return false; } try { // Check write client (master) health await Promise.race([ this.writeClient.ping(), new Promise((_, reject) => setTimeout( () => reject( new Error("Write client health check timeout") ), 2000 ) ) ]); this.isWriteHealthy = true; // Check read client health if it's different from write client if ( this.hasReplicas && this.readClient && this.readClient !== this.writeClient ) { try { await Promise.race([ this.readClient.ping(), new Promise((_, reject) => setTimeout( () => reject( new Error( "Read client health check timeout" ) ), 2000 ) ) ]); this.isReadHealthy = true; } catch (error) { logger.error( "Redis read client health check failed:", error ); this.isReadHealthy = false; } } else { this.isReadHealthy = this.isWriteHealthy; } this.updateOverallHealth(); return this.isHealthy; } catch (error) { logger.error("Redis write client health check failed:", error); this.isWriteHealthy = false; this.isReadHealthy = false; // If write fails, consider read as failed too for safety this.isHealthy = false; return false; } } public getClient(): Redis { return this.client!; } public getWriteClient(): Redis | null { return this.writeClient; } public getReadClient(): Redis | null { return this.readClient; } public hasReplicaSupport(): boolean { return this.hasReplicas; } public getHealthStatus(): { isEnabled: boolean; isHealthy: boolean; isWriteHealthy: boolean; isReadHealthy: boolean; hasReplicas: boolean; } { return { isEnabled: this.isEnabled, isHealthy: this.isHealthy, isWriteHealthy: this.isWriteHealthy, isReadHealthy: this.isReadHealthy, hasReplicas: this.hasReplicas }; } public async set( key: string, value: string, ttl?: number ): Promise { if (!this.isRedisEnabled() || !this.writeClient) return false; try { await this.executeWithRetry(async () => { if (ttl) { await this.writeClient!.setex(key, ttl, value); } else { await this.writeClient!.set(key, value); } }, "Redis SET"); return true; } catch (error) { logger.error("Redis SET error:", error); return false; } } public async get(key: string): Promise { if (!this.isRedisEnabled() || !this.readClient) return null; try { const fallbackOperation = this.hasReplicas && this.writeClient && this.isWriteHealthy ? () => this.writeClient!.get(key) : undefined; return await this.executeWithRetry( () => this.readClient!.get(key), "Redis GET", fallbackOperation ); } catch (error) { logger.error("Redis GET error:", error); return null; } } public async del(key: string): Promise { if (!this.isRedisEnabled() || !this.writeClient) return false; try { await this.executeWithRetry( () => this.writeClient!.del(key), "Redis DEL" ); return true; } catch (error) { logger.error("Redis DEL error:", error); return false; } } public async incr(key: string): Promise { if (!this.isRedisEnabled() || !this.writeClient) return 0; try { return await this.executeWithRetry( () => this.writeClient!.incr(key), "Redis INCR" ); } catch (error) { logger.error("Redis INCR error:", error); return 0; } } public async sadd(key: string, member: string): Promise { if (!this.isRedisEnabled() || !this.writeClient) return false; try { await this.executeWithRetry( () => this.writeClient!.sadd(key, member), "Redis SADD" ); return true; } catch (error) { logger.error("Redis SADD error:", error); return false; } } public async srem(key: string, member: string): Promise { if (!this.isRedisEnabled() || !this.writeClient) return false; try { await this.executeWithRetry( () => this.writeClient!.srem(key, member), "Redis SREM" ); return true; } catch (error) { logger.error("Redis SREM error:", error); return false; } } public async smembers(key: string): Promise { if (!this.isRedisEnabled() || !this.readClient) return []; try { const fallbackOperation = this.hasReplicas && this.writeClient && this.isWriteHealthy ? () => this.writeClient!.smembers(key) : undefined; return await this.executeWithRetry( () => this.readClient!.smembers(key), "Redis SMEMBERS", fallbackOperation ); } catch (error) { logger.error("Redis SMEMBERS error:", error); return []; } } public async hset( key: string, field: string, value: string ): Promise { if (!this.isRedisEnabled() || !this.writeClient) return false; try { await this.executeWithRetry( () => this.writeClient!.hset(key, field, value), "Redis HSET" ); return true; } catch (error) { logger.error("Redis HSET error:", error); return false; } } public async hget(key: string, field: string): Promise { if (!this.isRedisEnabled() || !this.readClient) return null; try { const fallbackOperation = this.hasReplicas && this.writeClient && this.isWriteHealthy ? () => this.writeClient!.hget(key, field) : undefined; return await this.executeWithRetry( () => this.readClient!.hget(key, field), "Redis HGET", fallbackOperation ); } catch (error) { logger.error("Redis HGET error:", error); return null; } } public async hdel(key: string, field: string): Promise { if (!this.isRedisEnabled() || !this.writeClient) return false; try { await this.executeWithRetry( () => this.writeClient!.hdel(key, field), "Redis HDEL" ); return true; } catch (error) { logger.error("Redis HDEL error:", error); return false; } } public async hgetall(key: string): Promise> { if (!this.isRedisEnabled() || !this.readClient) return {}; try { const fallbackOperation = this.hasReplicas && this.writeClient && this.isWriteHealthy ? () => this.writeClient!.hgetall(key) : undefined; return await this.executeWithRetry( () => this.readClient!.hgetall(key), "Redis HGETALL", fallbackOperation ); } catch (error) { logger.error("Redis HGETALL error:", error); return {}; } } public async publish(channel: string, message: string): Promise { if (!this.isRedisEnabled() || !this.publisher) return false; // Quick health check before attempting to publish const isHealthy = await this.checkRedisHealth(); if (!isHealthy) { logger.warn("Skipping Redis publish due to unhealthy connection"); return false; } try { await this.executeWithRetry(async () => { // Add timeout to prevent hanging return Promise.race([ this.publisher!.publish(channel, message), new Promise((_, reject) => setTimeout( () => reject(new Error("Redis publish timeout")), 3000 ) ) ]); }, "Redis PUBLISH"); return true; } catch (error) { logger.error("Redis PUBLISH error:", error); this.isHealthy = false; // Mark as unhealthy on error return false; } } public async subscribe( channel: string, callback: (channel: string, message: string) => void ): Promise { if (!this.isRedisEnabled() || !this.subscriber) return false; try { // Add callback to subscribers map if (!this.subscribers.has(channel)) { this.subscribers.set(channel, new Set()); // Only subscribe to the channel if it's the first subscriber await this.executeWithRetry(async () => { return Promise.race([ this.subscriber!.subscribe(channel), new Promise((_, reject) => setTimeout( () => reject( new Error("Redis subscribe timeout") ), 5000 ) ) ]); }, "Redis SUBSCRIBE"); } this.subscribers.get(channel)!.add(callback); return true; } catch (error) { logger.error("Redis SUBSCRIBE error:", error); this.isHealthy = false; return false; } } public async unsubscribe( channel: string, callback?: (channel: string, message: string) => void ): Promise { if (!this.isRedisEnabled() || !this.subscriber) return false; try { const channelSubscribers = this.subscribers.get(channel); if (!channelSubscribers) return true; if (callback) { // Remove specific callback channelSubscribers.delete(callback); if (channelSubscribers.size === 0) { this.subscribers.delete(channel); await this.executeWithRetry( () => this.subscriber!.unsubscribe(channel), "Redis UNSUBSCRIBE" ); } } else { // Remove all callbacks for this channel this.subscribers.delete(channel); await this.executeWithRetry( () => this.subscriber!.unsubscribe(channel), "Redis UNSUBSCRIBE" ); } return true; } catch (error) { logger.error("Redis UNSUBSCRIBE error:", error); return false; } } public async disconnect(): Promise { try { if (this.client) { await this.client.quit(); this.client = null; } if (this.writeClient) { await this.writeClient.quit(); this.writeClient = null; } if (this.readClient && this.readClient !== this.writeClient) { await this.readClient.quit(); this.readClient = null; } if (this.publisher) { await this.publisher.quit(); this.publisher = null; } if (this.subscriber) { await this.subscriber.quit(); this.subscriber = null; } this.subscribers.clear(); logger.info("Redis clients disconnected"); } catch (error) { logger.error("Error disconnecting Redis clients:", error); } } } export const redisManager = new RedisManager(); export const redis = redisManager.getClient(); export default redisManager; ================================================ FILE: server/private/lib/redisStore.ts ================================================ /* * 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 { Store, Options, IncrementResponse } from "express-rate-limit"; import { rateLimitService } from "./rateLimit"; import logger from "@server/logger"; /** * A Redis-backed rate limiting store for express-rate-limit that optimizes * for local read performance and batched writes to Redis. * * This store uses the same optimized rate limiting logic as the WebSocket * implementation, providing: * - Local caching for fast reads * - Batched writes to Redis to reduce load * - Automatic cleanup of expired entries * - Graceful fallback when Redis is unavailable */ export default class RedisStore implements Store { /** * The duration of time before which all hit counts are reset (in milliseconds). */ windowMs!: number; /** * Maximum number of requests allowed within the window. */ max!: number; /** * Optional prefix for Redis keys to avoid collisions. */ prefix: string; /** * Whether to skip incrementing on failed requests. */ skipFailedRequests: boolean; /** * Whether to skip incrementing on successful requests. */ skipSuccessfulRequests: boolean; /** * @constructor for RedisStore. * * @param options - Configuration options for the store. */ constructor( options: { prefix?: string; skipFailedRequests?: boolean; skipSuccessfulRequests?: boolean; } = {} ) { this.prefix = options.prefix || "express-rate-limit"; this.skipFailedRequests = options.skipFailedRequests || false; this.skipSuccessfulRequests = options.skipSuccessfulRequests || false; } /** * Method that actually initializes the store. Must be synchronous. * * @param options - The options used to setup express-rate-limit. */ init(options: Options): void { this.windowMs = options.windowMs; this.max = options.max as number; // logger.debug(`RedisStore initialized with windowMs: ${this.windowMs}, max: ${this.max}, prefix: ${this.prefix}`); } /** * Method to increment a client's hit counter. * * @param key - The identifier for a client (usually IP address). * @returns Promise resolving to the number of hits and reset time for that client. */ async increment(key: string): Promise { try { const clientId = `${this.prefix}:${key}`; const result = await rateLimitService.checkRateLimit( clientId, undefined, // No message type for HTTP requests this.max, undefined, // No message type limit this.windowMs ); // logger.debug(`Incremented rate limit for key: ${key} with max: ${this.max}, totalHits: ${result.totalHits}`); return { totalHits: result.totalHits || 1, resetTime: result.resetTime || new Date(Date.now() + this.windowMs) }; } catch (error) { logger.error(`RedisStore increment error for key ${key}:`, error); // Return safe defaults on error to prevent blocking requests return { totalHits: 1, resetTime: new Date(Date.now() + this.windowMs) }; } } /** * Method to decrement a client's hit counter. * Used when skipSuccessfulRequests or skipFailedRequests is enabled. * * @param key - The identifier for a client. */ async decrement(key: string): Promise { try { const clientId = `${this.prefix}:${key}`; await rateLimitService.decrementRateLimit(clientId); // logger.debug(`Decremented rate limit for key: ${key}`); } catch (error) { logger.error(`RedisStore decrement error for key ${key}:`, error); // Don't throw - decrement failures shouldn't block requests } } /** * Method to reset a client's hit counter. * * @param key - The identifier for a client. */ async resetKey(key: string): Promise { try { const clientId = `${this.prefix}:${key}`; await rateLimitService.resetKey(clientId); // logger.debug(`Reset rate limit for key: ${key}`); } catch (error) { logger.error(`RedisStore resetKey error for key ${key}:`, error); // Don't throw - reset failures shouldn't block requests } } /** * Method to reset everyone's hit counter. * * This method is optional and is never called by express-rate-limit. * We implement it for completeness but it's not recommended for production use * as it could be expensive with large datasets. */ async resetAll(): Promise { try { logger.warn( "RedisStore resetAll called - this operation can be expensive" ); // Force sync all pending data first await rateLimitService.forceSyncAllPendingData(); // Note: We don't actually implement full reset as it would require // scanning all Redis keys with our prefix, which could be expensive. // In production, it's better to let entries expire naturally. logger.info("RedisStore resetAll completed (pending data synced)"); } catch (error) { logger.error("RedisStore resetAll error:", error); // Don't throw - this is an optional method } } /** * Get current hit count for a key without incrementing. * This is a custom method not part of the Store interface. * * @param key - The identifier for a client. * @returns Current hit count and reset time, or null if no data exists. */ async getHits( key: string ): Promise<{ totalHits: number; resetTime: Date } | null> { try { const clientId = `${this.prefix}:${key}`; // Use checkRateLimit with max + 1 to avoid actually incrementing // but still get the current count const result = await rateLimitService.checkRateLimit( clientId, undefined, this.max + 1000, // Set artificially high to avoid triggering limit undefined, this.windowMs ); // Decrement since we don't actually want to count this check await rateLimitService.decrementRateLimit(clientId); return { totalHits: Math.max(0, (result.totalHits || 0) - 1), // Adjust for the decrement resetTime: result.resetTime || new Date(Date.now() + this.windowMs) }; } catch (error) { logger.error(`RedisStore getHits error for key ${key}:`, error); return null; } } /** * Cleanup method for graceful shutdown. * This is not part of the Store interface but is useful for cleanup. */ async shutdown(): Promise { try { // The rateLimitService handles its own cleanup logger.info("RedisStore shutdown completed"); } catch (error) { logger.error("RedisStore shutdown error:", error); } } } ================================================ FILE: server/private/lib/stripe.ts ================================================ /* * 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 Stripe from "stripe"; import privateConfig from "#private/lib/config"; import logger from "@server/logger"; import { noop } from "@server/lib/billing/usageService"; let stripe: Stripe | undefined = undefined; if (!noop()) { const stripeApiKey = privateConfig.getRawPrivateConfig().stripe?.secret_key; if (!stripeApiKey) { logger.error("Stripe secret key is not configured"); } stripe = new Stripe(stripeApiKey!); } export default stripe; ================================================ FILE: server/private/lib/traefik/getTraefikConfig.ts ================================================ /* * 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 { certificates, db, domainNamespaces, domains, exitNodes, loginPage, targetHealthCheck } from "@server/db"; import { and, eq, inArray, or, isNull, ne, isNotNull, desc, sql } from "drizzle-orm"; import logger from "@server/logger"; import config from "@server/lib/config"; import { orgs, resources, sites, Target, targets } from "@server/db"; import { sanitize, encodePath, validatePathRewriteConfig } from "@server/lib/traefik/utils"; import privateConfig from "#private/lib/config"; import createPathRewriteMiddleware from "@server/lib/traefik/middleware"; import { CertificateResult, getValidCertificatesForDomains } from "#private/lib/certificates"; import { build } from "@server/build"; const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectToRootMiddlewareName = "redirect-to-root"; const badgerMiddlewareName = "badger"; // Define extended target type with site information type TargetWithSite = Target & { resourceId: number; targetId: number; ip: string | null; method: string | null; port: number | null; internalPort: number | null; enabled: boolean; health: string | null; site: { siteId: number; type: string; subnet: string | null; exitNodeId: number | null; online: boolean; }; }; export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, generateLoginPageRouters = false, allowRawResources = true, allowMaintenancePage = true ): Promise { // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources const resourcesWithTargetsAndSites = await db .select({ // Resource fields resourceId: resources.resourceId, resourceName: resources.name, fullDomain: resources.fullDomain, ssl: resources.ssl, http: resources.http, proxyPort: resources.proxyPort, protocol: resources.protocol, subdomain: resources.subdomain, domainId: resources.domainId, enabled: resources.enabled, stickySession: resources.stickySession, tlsServerName: resources.tlsServerName, setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, headers: resources.headers, proxyProtocol: resources.proxyProtocol, proxyProtocolVersion: resources.proxyProtocolVersion, maintenanceModeEnabled: resources.maintenanceModeEnabled, maintenanceModeType: resources.maintenanceModeType, maintenanceTitle: resources.maintenanceTitle, maintenanceMessage: resources.maintenanceMessage, maintenanceEstimatedTime: resources.maintenanceEstimatedTime, // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, ip: targets.ip, method: targets.method, port: targets.port, internalPort: targets.internalPort, hcHealth: targetHealthCheck.hcHealth, path: targets.path, pathMatchType: targets.pathMatchType, rewritePath: targets.rewritePath, rewritePathType: targets.rewritePathType, priority: targets.priority, // Site fields siteId: sites.siteId, siteType: sites.type, siteOnline: sites.online, subnet: sites.subnet, exitNodeId: sites.exitNodeId, // Namespace domainNamespaceId: domainNamespaces.domainNamespaceId, // Certificate certificateStatus: certificates.status, domainCertResolver: domains.certResolver, preferWildcardCert: domains.preferWildcardCert }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) .leftJoin(certificates, eq(certificates.domainId, resources.domainId)) .leftJoin(domains, eq(domains.domainId, resources.domainId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) ) .leftJoin( domainNamespaces, eq(domainNamespaces.domainId, resources.domainId) ) // THIS IS CLOUD ONLY TO FILTER OUT THE DOMAIN NAMESPACES IF REQUIRED .where( and( eq(targets.enabled, true), eq(resources.enabled, true), or( eq(sites.exitNodeId, exitNodeId), and( isNull(sites.exitNodeId), sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`, // only allow local sites if "local" is in siteTypes eq(sites.type, "local"), sql`(${build != "saas" ? 1 : 0} = 1)` // Dont allow undefined local sites in cloud ) ), inArray(sites.type, siteTypes), allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true : eq(resources.http, true) ) ) .orderBy(desc(targets.priority), targets.targetId); // stable ordering // Group by resource and include targets with their unique site data const resourcesMap = new Map(); resourcesWithTargetsAndSites.forEach((row) => { const resourceId = row.resourceId; const resourceName = sanitize(row.resourceName) || ""; const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b") const pathMatchType = row.pathMatchType || ""; const rewritePath = row.rewritePath || ""; const rewritePathType = row.rewritePathType || ""; const priority = row.priority ?? 100; if (filterOutNamespaceDomains && row.domainNamespaceId) { return; } // Create a unique key combining resourceId, path config, and rewrite config const pathKey = [ targetPath, pathMatchType, rewritePath, rewritePathType ] .filter(Boolean) .join("-"); const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); const key = sanitize(mapKey); if (!resourcesMap.has(mapKey)) { const validation = validatePathRewriteConfig( row.path, row.pathMatchType, row.rewritePath, row.rewritePathType ); if (!validation.isValid) { logger.debug( `Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}` ); return; } resourcesMap.set(mapKey, { resourceId: row.resourceId, name: resourceName, key: key, fullDomain: row.fullDomain, ssl: row.ssl, http: row.http, proxyPort: row.proxyPort, protocol: row.protocol, subdomain: row.subdomain, domainId: row.domainId, enabled: row.enabled, stickySession: row.stickySession, tlsServerName: row.tlsServerName, setHostHeader: row.setHostHeader, enableProxy: row.enableProxy, targets: [], headers: row.headers, proxyProtocol: row.proxyProtocol, proxyProtocolVersion: row.proxyProtocolVersion ?? 1, path: row.path, // the targets will all have the same path pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, priority: priority, // may be null, we fallback later domainCertResolver: row.domainCertResolver, preferWildcardCert: row.preferWildcardCert, maintenanceModeEnabled: row.maintenanceModeEnabled, maintenanceModeType: row.maintenanceModeType, maintenanceTitle: row.maintenanceTitle, maintenanceMessage: row.maintenanceMessage, maintenanceEstimatedTime: row.maintenanceEstimatedTime }); } // Add target with its associated site data resourcesMap.get(mapKey).targets.push({ resourceId: row.resourceId, targetId: row.targetId, ip: row.ip, method: row.method, port: row.port, internalPort: row.internalPort, enabled: row.targetEnabled, health: row.hcHealth, site: { siteId: row.siteId, type: row.siteType, subnet: row.subnet, exitNodeId: row.exitNodeId, online: row.siteOnline } }); }); let validCerts: CertificateResult[] = []; if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { // create a list of all domains to get certs for const domains = new Set(); for (const resource of resourcesMap.values()) { if (resource.enabled && resource.ssl && resource.fullDomain) { domains.add(resource.fullDomain); } } // get the valid certs for these domains validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often // logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`); } const config_output: any = { http: { middlewares: { [redirectHttpsMiddlewareName]: { redirectScheme: { scheme: "https" } }, [redirectToRootMiddlewareName]: { redirectRegex: { regex: "^(https?)://([^/]+)(/.*)?", replacement: "${1}://${2}/auth/org", permanent: false } } } } }; // get the key and the resource for (const [, resource] of resourcesMap.entries()) { const targets = resource.targets as TargetWithSite[]; const key = resource.key; const routerName = `${key}-${resource.name}-router`; const serviceName = `${key}-${resource.name}-service`; const fullDomain = `${resource.fullDomain}`; const transportName = `${key}-transport`; const headersMiddlewareName = `${key}-headers-middleware`; if (!resource.enabled) { continue; } if (resource.http) { if (!resource.domainId) { continue; } if (!resource.fullDomain) { continue; } // add routers and services empty objects if they don't exist if (!config_output.http.routers) { config_output.http.routers = {}; } if (!config_output.http.services) { config_output.http.services = {}; } const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; const routerMiddlewares = [ badgerMiddlewareName, ...additionalMiddlewares ]; let rule = `Host(\`${fullDomain}\`)`; // priority logic let priority: number; if (resource.priority && resource.priority != 100) { priority = resource.priority; } else { priority = 100; if (resource.path && resource.pathMatchType) { priority += 10; if (resource.pathMatchType === "exact") { priority += 5; } else if (resource.pathMatchType === "prefix") { priority += 3; } else if (resource.pathMatchType === "regex") { priority += 2; } if (resource.path === "/") { priority = 1; // lowest for catch-all } } } let tls = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { const domainParts = fullDomain.split("."); let wildCard; if (domainParts.length <= 2) { wildCard = `*.${domainParts.join(".")}`; } else { wildCard = `*.${domainParts.slice(1).join(".")}`; } if (!resource.subdomain) { wildCard = resource.fullDomain; } const globalDefaultResolver = config.getRawConfig().traefik.cert_resolver; const globalDefaultPreferWildcard = config.getRawConfig().traefik.prefer_wildcard_cert; const domainCertResolver = resource.domainCertResolver; const preferWildcardCert = resource.preferWildcardCert; let resolverName: string | undefined; let preferWildcard: boolean | undefined; // Handle both letsencrypt & custom cases if (domainCertResolver) { resolverName = domainCertResolver.trim(); } else { resolverName = globalDefaultResolver; } if ( preferWildcardCert !== undefined && preferWildcardCert !== null ) { preferWildcard = preferWildcardCert; } else { preferWildcard = globalDefaultPreferWildcard; } tls = { certResolver: resolverName, ...(preferWildcard ? { domains: [ { main: wildCard } ] } : {}) }; } else { // find a cert that matches the full domain, if not continue const matchingCert = validCerts.find( (cert) => cert.queriedDomain === resource.fullDomain ); if (!matchingCert) { logger.debug( `No matching certificate found for domain: ${resource.fullDomain}` ); continue; } } if (resource.ssl) { config_output.http.routers![routerName + "-redirect"] = { entryPoints: [ config.getRawConfig().traefik.http_entrypoint ], middlewares: [redirectHttpsMiddlewareName], service: serviceName, rule: rule, priority: priority }; } const availableServers = targets.filter((target) => { if (!target.enabled) return false; if (!target.site.online) return false; if (target.health == "unhealthy") return false; return true; }); const hasHealthyServers = availableServers.length > 0; let showMaintenancePage = false; if (resource.maintenanceModeEnabled) { if (resource.maintenanceModeType === "forced") { showMaintenancePage = true; // logger.debug( // `Resource ${resource.name} (${fullDomain}) is in FORCED maintenance mode` // ); } else if (resource.maintenanceModeType === "automatic") { showMaintenancePage = !hasHealthyServers; // if (showMaintenancePage) { // logger.warn( // `Resource ${resource.name} (${fullDomain}) has no healthy servers - showing maintenance page (AUTOMATIC mode)` // ); // } } } if (showMaintenancePage && allowMaintenancePage) { const maintenanceServiceName = `${key}-maintenance-service`; const maintenanceRouterName = `${key}-maintenance-router`; const rewriteMiddlewareName = `${key}-maintenance-rewrite`; const entrypointHttp = config.getRawConfig().traefik.http_entrypoint; const entrypointHttps = config.getRawConfig().traefik.https_entrypoint; const fullDomain = resource.fullDomain; const domainParts = fullDomain.split("."); const wildCard = resource.subdomain ? `*.${domainParts.slice(1).join(".")}` : fullDomain; const maintenancePort = config.getRawConfig().server.next_port; const maintenanceHost = config.getRawConfig().server.internal_hostname; config_output.http.services[maintenanceServiceName] = { loadBalancer: { servers: [ { url: `http://${maintenanceHost}:${maintenancePort}` } ], passHostHeader: true } }; // middleware to rewrite path to /maintenance-screen if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } config_output.http.middlewares[rewriteMiddlewareName] = { replacePathRegex: { regex: "^/(.*)", replacement: "/maintenance-screen" } }; config_output.http.routers[maintenanceRouterName] = { entryPoints: [ resource.ssl ? entrypointHttps : entrypointHttp ], service: maintenanceServiceName, middlewares: [rewriteMiddlewareName], rule: rule, priority: 2000, ...(resource.ssl ? { tls } : {}) }; // Router to allow Next.js assets to load without rewrite config_output.http.routers[`${maintenanceRouterName}-assets`] = { entryPoints: [ resource.ssl ? entrypointHttps : entrypointHttp ], service: maintenanceServiceName, rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, priority: 2001, ...(resource.ssl ? { tls } : {}) }; // logger.info(`Maintenance mode active for ${fullDomain}`); continue; } // Handle path rewriting middleware if ( resource.rewritePath !== null && resource.path !== null && resource.pathMatchType && resource.rewritePathType ) { // Create a unique middleware name const rewriteMiddlewareName = `rewrite-r${resource.resourceId}-${key}`; try { const rewriteResult = createPathRewriteMiddleware( rewriteMiddlewareName, resource.path, resource.pathMatchType, resource.rewritePath, resource.rewritePathType ); // Initialize middlewares object if it doesn't exist if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } // the middleware to the config Object.assign( config_output.http.middlewares, rewriteResult.middlewares ); // middlewares to the router middleware chain if (rewriteResult.chain) { // For chained middlewares (like stripPrefix + addPrefix) routerMiddlewares.push(...rewriteResult.chain); } else { // Single middleware routerMiddlewares.push(rewriteMiddlewareName); } // logger.debug( // `Created path rewrite middleware ${rewriteMiddlewareName}: ${resource.pathMatchType}(${resource.path}) -> ${resource.rewritePathType}(${resource.rewritePath})` // ); } catch (error) { logger.error( `Failed to create path rewrite middleware for resource ${resource.resourceId}: ${error}` ); } } if (resource.headers || resource.setHostHeader) { // if there are headers, parse them into an object const headersObj: { [key: string]: string } = {}; if (resource.headers) { let headersArr: { name: string; value: string }[] = []; try { headersArr = JSON.parse(resource.headers) as { name: string; value: string; }[]; } catch (e) { logger.warn( `Failed to parse headers for resource ${resource.resourceId}: ${e}` ); } headersArr.forEach((header) => { headersObj[header.name] = header.value; }); } if (resource.setHostHeader) { headersObj["Host"] = resource.setHostHeader; } // check if the object is not empty if (Object.keys(headersObj).length > 0) { // Add the headers middleware if (!config_output.http.middlewares) { config_output.http.middlewares = {}; } config_output.http.middlewares[headersMiddlewareName] = { headers: { customRequestHeaders: headersObj } }; routerMiddlewares.push(headersMiddlewareName); } } if (resource.path && resource.pathMatchType) { //priority += 1; // add path to rule based on match type let path = resource.path; // if the path doesn't start with a /, add it if (!path.startsWith("/")) { path = `/${path}`; } if (resource.pathMatchType === "exact") { rule += ` && Path(\`${path}\`)`; } else if (resource.pathMatchType === "prefix") { rule += ` && PathPrefix(\`${path}\`)`; } else if (resource.pathMatchType === "regex") { rule += ` && PathRegexp(\`${resource.path}\`)`; // this is the raw path because it's a regex } } config_output.http.routers![routerName] = { entryPoints: [ resource.ssl ? config.getRawConfig().traefik.https_entrypoint : config.getRawConfig().traefik.http_entrypoint ], middlewares: routerMiddlewares, service: serviceName, rule: rule, priority: priority, ...(resource.ssl ? { tls } : {}) }; config_output.http.services![serviceName] = { loadBalancer: { servers: (() => { // Check if any sites are online // THIS IS SO THAT THERE IS SOME IMMEDIATE FEEDBACK // EVEN IF THE SITES HAVE NOT UPDATED YET FROM THE // RECEIVE BANDWIDTH ENDPOINT. // TODO: HOW TO HANDLE ^^^^^^ BETTER const anySitesOnline = targets.some( (target) => target.site.online || target.site.type === "local" || target.site.type === "wireguard" ); return ( targets .filter((target) => { if (!target.enabled) { return false; } if (target.health == "unhealthy") { return false; } // If any sites are online, exclude offline sites if (anySitesOnline && !target.site.online) { return false; } if ( target.site.type === "local" || target.site.type === "wireguard" ) { if ( !target.ip || !target.port || !target.method ) { return false; } } else if (target.site.type === "newt") { if ( !target.internalPort || !target.method || !target.site.subnet ) { return false; } } return true; }) .map((target) => { if ( target.site.type === "local" || target.site.type === "wireguard" ) { return { url: `${target.method}://${target.ip}:${target.port}` }; } else if (target.site.type === "newt") { const ip = target.site.subnet!.split("/")[0]; return { url: `${target.method}://${ip}:${target.internalPort}` }; } }) // filter out duplicates .filter( (v, i, a) => a.findIndex( (t) => t && v && t.url === v.url ) === i ) ); })(), ...(resource.stickySession ? { sticky: { cookie: { name: "p_sticky", // TODO: make this configurable via config.yml like other cookies secure: resource.ssl, httpOnly: true } } } : {}) } }; // Add the serversTransport if TLS server name is provided if (resource.tlsServerName) { if (!config_output.http.serversTransports) { config_output.http.serversTransports = {}; } config_output.http.serversTransports![transportName] = { serverName: resource.tlsServerName, //unfortunately the following needs to be set. traefik doesn't merge the default serverTransport settings // if defined in the static config and here. if not set, self-signed certs won't work insecureSkipVerify: true }; config_output.http.services![ serviceName ].loadBalancer.serversTransport = transportName; } } else { // Non-HTTP (TCP/UDP) configuration if (!resource.enableProxy) { continue; } const protocol = resource.protocol.toLowerCase(); const port = resource.proxyPort; if (!port) { continue; } if (!config_output[protocol]) { config_output[protocol] = { routers: {}, services: {} }; } config_output[protocol].routers[routerName] = { entryPoints: [`${protocol}-${port}`], service: serviceName, ...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {}) }; const ppPrefix = config.getRawConfig().traefik.pp_transport_prefix; config_output[protocol].services[serviceName] = { loadBalancer: { servers: (() => { // Check if any sites are online const anySitesOnline = targets.some( (target) => target.site.online || target.site.type === "local" || target.site.type === "wireguard" ); return targets .filter((target) => { if (!target.enabled) { return false; } // If any sites are online, exclude offline sites if (anySitesOnline && !target.site.online) { return false; } if ( target.site.type === "local" || target.site.type === "wireguard" ) { if (!target.ip || !target.port) { return false; } } else if (target.site.type === "newt") { if ( !target.internalPort || !target.site.subnet ) { return false; } } return true; }) .map((target) => { if ( target.site.type === "local" || target.site.type === "wireguard" ) { return { address: `${target.ip}:${target.port}` }; } else if (target.site.type === "newt") { const ip = target.site.subnet!.split("/")[0]; return { address: `${ip}:${target.internalPort}` }; } }); })(), ...(resource.proxyProtocol && protocol == "tcp" // proxy protocol only works for tcp ? { serversTransport: `${ppPrefix}${resource.proxyProtocolVersion || 1}@file` // TODO: does @file here cause issues? } : {}), ...(resource.stickySession ? { sticky: { ipStrategy: { depth: 0, sourcePort: true } } } : {}) } }; } } if (generateLoginPageRouters) { const exitNodeLoginPages = await db .select({ loginPageId: loginPage.loginPageId, fullDomain: loginPage.fullDomain, exitNodeId: exitNodes.exitNodeId, domainId: loginPage.domainId }) .from(loginPage) .innerJoin( exitNodes, eq(exitNodes.exitNodeId, loginPage.exitNodeId) ) .where(eq(exitNodes.exitNodeId, exitNodeId)); let validCertsLoginPages: CertificateResult[] = []; if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { // create a list of all domains to get certs for const domains = new Set(); for (const lp of exitNodeLoginPages) { if (lp.fullDomain) { domains.add(lp.fullDomain); } } // get the valid certs for these domains validCertsLoginPages = await getValidCertificatesForDomains( domains, true ); // we are caching here because this is called often } if (exitNodeLoginPages.length > 0) { if (!config_output.http.services) { config_output.http.services = {}; } if (!config_output.http.services["landing-service"]) { config_output.http.services["landing-service"] = { loadBalancer: { servers: [ { url: `http://${ config.getRawConfig().server .internal_hostname }:${config.getRawConfig().server.next_port}` } ] } }; } for (const lp of exitNodeLoginPages) { if (!lp.domainId) { continue; } if (!lp.fullDomain) { continue; } const tls = {}; if ( !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns ) { // TODO: we need to add the wildcard logic here too } else { // find a cert that matches the full domain, if not continue const matchingCert = validCertsLoginPages.find( (cert) => cert.queriedDomain === lp.fullDomain ); if (!matchingCert) { logger.debug( `No matching certificate found for login page domain: ${lp.fullDomain}` ); continue; } } // auth-allowed: // rule: "Host(`auth.pangolin.internal`) && (PathRegexp(`^/auth/resource/[0-9]+$`) || PathPrefix(`/_next`))" // service: next-service // entryPoints: // - websecure const routerName = `loginpage-${lp.loginPageId}`; const fullDomain = `${lp.fullDomain}`; if (!config_output.http.routers) { config_output.http.routers = {}; } config_output.http.routers![routerName + "-router"] = { entryPoints: [ config.getRawConfig().traefik.https_entrypoint ], service: "landing-service", rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`, priority: 203, tls: tls }; // auth-catchall: // rule: "Host(`auth.example.com`)" // middlewares: // - redirect-to-root // service: next-service // entryPoints: // - web config_output.http.routers![routerName + "-catchall"] = { entryPoints: [ config.getRawConfig().traefik.https_entrypoint ], middlewares: [redirectToRootMiddlewareName], service: "landing-service", rule: `Host(\`${fullDomain}\`)`, priority: 202, tls: tls }; // we need to add a redirect from http to https too config_output.http.routers![routerName + "-redirect"] = { entryPoints: [ config.getRawConfig().traefik.http_entrypoint ], middlewares: [redirectHttpsMiddlewareName], service: "landing-service", rule: `Host(\`${fullDomain}\`)`, priority: 201 }; } } } return config_output; } ================================================ FILE: server/private/lib/traefik/index.ts ================================================ /* * 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 "./getTraefikConfig"; ================================================ FILE: server/private/license/license.ts ================================================ /* * 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 { db, HostMeta, sites, users } from "@server/db"; import { hostMeta, licenseKey } from "@server/db"; import logger from "@server/logger"; import NodeCache from "node-cache"; import { validateJWT } from "./licenseJwt"; import { count, eq } from "drizzle-orm"; import moment from "moment"; import { encrypt, decrypt } from "@server/lib/crypto"; import { LicenseKeyCache, LicenseKeyTier, LicenseKeyType, LicenseStatus } from "@server/license/license"; import { setHostMeta } from "@server/lib/hostMeta"; type ActivateLicenseKeyAPIResponse = { data: { instanceId: string; }; success: boolean; error: string; message: string; status: number; }; type ValidateLicenseAPIResponse = { data: { licenseKeys: { [key: string]: string; }; }; success: boolean; error: string; message: string; status: number; }; type TokenPayload = { valid: boolean; type: LicenseKeyType; tier: LicenseKeyTier; quantity: number; quantity_2: number; terminateAt: string; // ISO iat: number; // Issued at }; export class License { private phoneHomeInterval = 6 * 60 * 60; // 6 hours = 6 * 60 * 60 = 21600 seconds private serverBaseUrl = "https://api.fossorial.io"; private validationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/validate`; private activationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/activate`; private statusCache = new NodeCache(); private licenseKeyCache = new NodeCache(); private statusKey = "status"; private serverSecret!: string; private phoneHomeFailureCount = 0; private checkInProgress = false; private doRecheck = false; private publicKey = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF FNkRDggQfYO6Ae+EWHGujZ9WYAZ10spLh9F/zoLhhr3XhsjpoRXwMfgNuO5HstWf CYM20I0l7EUUMWEyWd4tZLd+5XQ4jY5xWOCWyFJAGQSp7flcRmxdfde+l+xg9eKl apbY84aVp09/GqM96hCS+CsQZrhohu/aOqYVB/eAhF01qsbmiZ7Y3WtdhTldveYt h4mZWGmjf8d/aEgePf/tk1gp0BUxf+Ae5yqoAqU+6aiFbjJ7q1kgxc18PWFGfE9y zSk+OZk887N5ThQ52154+oOUCMMR2Y3t5OH1hVZod51vuY2u5LsQXsf+87PwB91y LQIDAQAB -----END PUBLIC KEY-----`; constructor(private hostMeta: HostMeta) { setInterval(async () => { this.doRecheck = true; await this.check(); this.doRecheck = false; }, 1000 * this.phoneHomeInterval); } public listKeys(): LicenseKeyCache[] { const keys = this.licenseKeyCache.keys(); return keys.map((key) => { return this.licenseKeyCache.get(key)!; }); } public setServerSecret(secret: string) { this.serverSecret = secret; } public async forceRecheck() { this.statusCache.flushAll(); this.licenseKeyCache.flushAll(); this.phoneHomeFailureCount = 0; return await this.check(); } public async isUnlocked(): Promise { const status = await this.check(); if (status.isHostLicensed) { if (status.isLicenseValid) { return true; } } return false; } public async check(): Promise { // If a check is already in progress, return the last known status if (this.checkInProgress) { logger.debug( "License check already in progress, returning last known status" ); const lastStatus = this.statusCache.get(this.statusKey) as | LicenseStatus | undefined; if (lastStatus) { return lastStatus; } // If no cached status exists, return default status return { hostId: this.hostMeta.hostMetaId, isHostLicensed: true, isLicenseValid: false }; } // Count used sites and users for license comparison const [siteCountRes] = await db .select({ value: count() }) .from(sites); const [userCountRes] = await db .select({ value: count() }) .from(users); const status: LicenseStatus = { hostId: this.hostMeta.hostMetaId, isHostLicensed: true, isLicenseValid: false, usedSites: siteCountRes?.value ?? 0, usedUsers: userCountRes?.value ?? 0 }; this.checkInProgress = true; try { if (!this.doRecheck && this.statusCache.has(this.statusKey)) { const res = this.statusCache.get("status") as LicenseStatus; res.usedSites = status.usedSites; res.usedUsers = status.usedUsers; return res; } logger.debug("Checking license status..."); // Build new cache in temporary Map before invalidating old cache const newCache = new Map(); const allKeysRes = await db.select().from(licenseKey); if (allKeysRes.length === 0) { status.isHostLicensed = false; // Invalidate all and set new cache (empty) this.licenseKeyCache.flushAll(); this.statusCache.set(this.statusKey, status); return status; } let foundHostKey = false; // Validate stored license keys for (const key of allKeysRes) { try { // Decrypt the license key and token const decryptedKey = decrypt( key.licenseKeyId, this.serverSecret ); const decryptedToken = decrypt( key.token, this.serverSecret ); const payload = validateJWT( decryptedToken, this.publicKey ); newCache.set(decryptedKey, { licenseKey: decryptedKey, licenseKeyEncrypted: key.licenseKeyId, valid: payload.valid, type: payload.type, tier: payload.tier, iat: new Date(payload.iat * 1000), terminateAt: new Date(payload.terminateAt), quantity: payload.quantity, quantity_2: payload.quantity_2 }); if (payload.type === "host") { foundHostKey = true; } } catch (e) { logger.error( `Error validating license key: ${key.licenseKeyId}` ); logger.error(e); newCache.set(key.licenseKeyId, { licenseKey: key.licenseKeyId, licenseKeyEncrypted: key.licenseKeyId, valid: false }); } } if (!foundHostKey && allKeysRes.length) { logger.debug("No host license key found"); status.isHostLicensed = false; } const keys = allKeysRes.map((key) => ({ licenseKey: decrypt(key.licenseKeyId, this.serverSecret), instanceId: decrypt(key.instanceId, this.serverSecret) })); let apiResponse: ValidateLicenseAPIResponse | undefined; try { // Phone home to validate license keys apiResponse = await this.phoneHome(keys, false); if (!apiResponse?.success) { throw new Error(apiResponse?.error); } // Reset failure count on success this.phoneHomeFailureCount = 0; } catch (e) { this.phoneHomeFailureCount++; if (this.phoneHomeFailureCount === 1) { // First failure: fail silently logger.error("Error communicating with license server:"); logger.error(e); logger.error( `Allowing failure. Will retry one more time at next run interval.` ); // return last known good status return this.statusCache.get( this.statusKey ) as LicenseStatus; } else { // Subsequent failures: fail abruptly throw e; } } // Check and update all license keys with server response for (const key of keys) { try { const cached = newCache.get(key.licenseKey)!; const licenseKeyRes = apiResponse?.data?.licenseKeys[key.licenseKey]; if (!apiResponse || !licenseKeyRes) { logger.debug( `No response from server for license key: ${key.licenseKey}` ); if (cached.iat) { const exp = moment(cached.iat) .add(7, "days") .toDate(); if (exp > new Date()) { logger.debug( `Using cached license key: ${key.licenseKey}, valid ${cached.valid}` ); continue; } } logger.debug( `Can't trust license key: ${key.licenseKey}` ); cached.valid = false; newCache.set(key.licenseKey, cached); continue; } const payload = validateJWT( licenseKeyRes, this.publicKey ); cached.valid = payload.valid; cached.type = payload.type; cached.tier = payload.tier; cached.iat = new Date(payload.iat * 1000); cached.terminateAt = new Date(payload.terminateAt); cached.quantity = payload.quantity; cached.quantity_2 = payload.quantity_2; // Encrypt the updated token before storing const encryptedKey = encrypt( key.licenseKey, this.serverSecret ); const encryptedToken = encrypt( licenseKeyRes, this.serverSecret ); await db .update(licenseKey) .set({ token: encryptedToken }) .where(eq(licenseKey.licenseKeyId, encryptedKey)); newCache.set(key.licenseKey, cached); } catch (e) { logger.error(`Error validating license key: ${key}`); logger.error(e); } } // Compute host status: quantity = users, quantity_2 = sites for (const key of keys) { const cached = newCache.get(key.licenseKey)!; if (cached.type === "host") { status.isLicenseValid = cached.valid; status.tier = cached.tier; } if (!cached.valid) { continue; } // Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites) if ( cached.quantity_2 !== undefined && cached.quantity_2 >= 0 ) { status.maxSites = (status.maxSites ?? 0) + cached.quantity_2; } if (cached.quantity !== undefined && cached.quantity >= 0) { status.maxUsers = (status.maxUsers ?? 0) + cached.quantity; } } // Invalidate license if over user or site limits if ( (status.maxSites !== undefined && (status.usedSites ?? 0) > status.maxSites) || (status.maxUsers !== undefined && (status.usedUsers ?? 0) > status.maxUsers) ) { status.isLicenseValid = false; } // Invalidate old cache and set new cache this.licenseKeyCache.flushAll(); for (const [key, value] of newCache.entries()) { this.licenseKeyCache.set(key, value); } } catch (error) { logger.error("Error checking license status:"); logger.error(error); } finally { this.checkInProgress = false; } this.statusCache.set(this.statusKey, status); return status; } public async activateLicenseKey(key: string) { // Encrypt the license key before storing const encryptedKey = encrypt(key, this.serverSecret); const [existingKey] = await db .select() .from(licenseKey) .where(eq(licenseKey.licenseKeyId, encryptedKey)) .limit(1); if (existingKey) { throw new Error("License key already exists"); } let instanceId: string | undefined; try { // Call activate const apiResponse = await fetch(this.activationServerUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ licenseKey: key, instanceName: this.hostMeta.hostMetaId }) }); const data = await apiResponse.json(); if (!data.success) { throw new Error(`${data.message || data.error}`); } const response = data as ActivateLicenseKeyAPIResponse; if (!response.data) { throw new Error("No response from server"); } if (!response.data.instanceId) { throw new Error("No instance ID in response"); } logger.debug("Activated license key, instance ID:", { instanceId: response.data.instanceId }); instanceId = response.data.instanceId; } catch (error) { throw Error(`Error activating license key: ${error}`); } // Phone home to validate license key const keys = [ { licenseKey: key, instanceId: instanceId! } ]; let validateResponse: ValidateLicenseAPIResponse; try { validateResponse = await this.phoneHome(keys, false); if (!validateResponse) { throw new Error("No response from server"); } if (!validateResponse.success) { throw new Error(validateResponse.error); } // Validate the license key const licenseKeyRes = validateResponse.data.licenseKeys[key]; if (!licenseKeyRes) { throw new Error("Invalid license key"); } const payload = validateJWT( licenseKeyRes, this.publicKey ); if (!payload.valid) { throw new Error("Invalid license key"); } const encryptedToken = encrypt(licenseKeyRes, this.serverSecret); // Encrypt the instanceId before storing const encryptedInstanceId = encrypt(instanceId!, this.serverSecret); // Store the license key in the database await db.insert(licenseKey).values({ licenseKeyId: encryptedKey, token: encryptedToken, instanceId: encryptedInstanceId }); } catch (error) { throw Error(`Error validating license key: ${error}`); } // Invalidate the cache and re-compute the status return await this.forceRecheck(); } private async phoneHome( keys: { licenseKey: string; instanceId: string; }[], doDecrypt = true ): Promise { // Decrypt the instanceIds before sending to the server const decryptedKeys = keys.map((key) => ({ licenseKey: key.licenseKey, instanceId: key.instanceId && doDecrypt ? decrypt(key.instanceId, this.serverSecret) : key.instanceId })); const maxAttempts = 10; const initialRetryDelay = 1 * 1000; // 1 seconds const exponentialFactor = 1.2; let lastError: Error | undefined; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { const response = await fetch(this.validationServerUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ licenseKeys: decryptedKeys, instanceName: this.hostMeta.hostMetaId }) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); return data as ValidateLicenseAPIResponse; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); if (attempt < maxAttempts) { // Calculate exponential backoff delay const retryDelay = Math.floor( initialRetryDelay * Math.pow(exponentialFactor, attempt - 1) ); logger.debug( `License validation request failed (attempt ${attempt}/${maxAttempts}), retrying in ${retryDelay} ms...` ); await new Promise((resolve) => setTimeout(resolve, retryDelay) ); } else { logger.error( `License validation request failed after ${maxAttempts} attempts` ); throw lastError; } } } throw lastError || new Error("License validation request failed"); } } await setHostMeta(); const [info] = await db.select().from(hostMeta).limit(1); if (!info) { throw new Error("Host information not found"); } export const license = new License(info); export default license; ================================================ FILE: server/private/license/licenseJwt.ts ================================================ /* * 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"; /** * Validates a JWT using a public key * @param token - The JWT to validate * @param publicKey - The public key used for verification (PEM format) * @returns The decoded payload if validation succeeds, throws an error otherwise */ function validateJWT(token: string, publicKey: string): Payload { // Split the JWT into its three parts const parts = token.split("."); if (parts.length !== 3) { throw new Error("Invalid JWT format"); } const [encodedHeader, encodedPayload, signature] = parts; // Decode the header to get the algorithm const header = JSON.parse(Buffer.from(encodedHeader, "base64").toString()); const algorithm = header.alg; // Verify the signature const signatureInput = `${encodedHeader}.${encodedPayload}`; const isValid = verify(signatureInput, signature, publicKey, algorithm); if (!isValid) { throw new Error("Invalid signature"); } // Decode the payload const payload = JSON.parse( Buffer.from(encodedPayload, "base64").toString() ); // Check if the token has expired const now = Math.floor(Date.now() / 1000); if (payload.exp && payload.exp < now) { throw new Error("Token has expired"); } return payload; } /** * Verifies the signature of a JWT */ function verify( input: string, signature: string, publicKey: string, algorithm: string ): boolean { let verifyAlgorithm: string; // Map JWT algorithm name to Node.js crypto algorithm name switch (algorithm) { case "RS256": verifyAlgorithm = "RSA-SHA256"; break; case "RS384": verifyAlgorithm = "RSA-SHA384"; break; case "RS512": verifyAlgorithm = "RSA-SHA512"; break; case "ES256": verifyAlgorithm = "SHA256"; break; case "ES384": verifyAlgorithm = "SHA384"; break; case "ES512": verifyAlgorithm = "SHA512"; break; default: throw new Error(`Unsupported algorithm: ${algorithm}`); } // Convert base64url signature to standard base64 const base64Signature = base64URLToBase64(signature); // Verify the signature const verifier = crypto.createVerify(verifyAlgorithm); verifier.update(input); return verifier.verify(publicKey, base64Signature, "base64"); } /** * Converts base64url format to standard base64 */ function base64URLToBase64(base64url: string): string { // Add padding if needed let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); const pad = base64.length % 4; if (pad) { if (pad === 1) { throw new Error("Invalid base64url string"); } base64 += "=".repeat(4 - pad); } return base64; } export { validateJWT }; ================================================ FILE: server/private/middlewares/index.ts ================================================ /* * 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 "./verifyCertificateAccess"; export * from "./verifyRemoteExitNodeAccess"; export * from "./verifyIdpAccess"; export * from "./verifyLoginPageAccess"; export * from "./logActionAudit"; export * from "./verifySubscription"; export * from "./verifyValidLicense"; ================================================ FILE: server/private/middlewares/logActionAudit.ts ================================================ /* * 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 { ActionsEnum } from "@server/auth/actions"; import { actionAuditLog, logsDb, db, orgs } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { and, eq, lt } from "drizzle-orm"; import cache from "#private/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; async function getActionDays(orgId: string): Promise { // check cache first const cached = await cache.get(`org_${orgId}_actionDays`); if (cached !== undefined) { return cached; } const [org] = await db .select({ settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction }) .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (!org) { return 0; } // store the result in cache await cache.set( `org_${orgId}_actionDays`, org.settingsLogRetentionDaysAction, 300 ); return org.settingsLogRetentionDaysAction; } export async function cleanUpOldLogs(orgId: string, retentionDays: number) { const cutoffTimestamp = calculateCutoffTimestamp(retentionDays); try { await logsDb .delete(actionAuditLog) .where( and( lt(actionAuditLog.timestamp, cutoffTimestamp), eq(actionAuditLog.orgId, orgId) ) ); logger.debug( `Cleaned up action audit logs older than ${retentionDays} days` ); } catch (error) { logger.error("Error cleaning up old action audit logs:", error); } } export function logActionAudit(action: ActionsEnum) { return async function ( req: Request, res: Response, next: NextFunction ): Promise { try { let orgId; let actorType; let actor; let actorId; const user = req.user; if (user) { const userOrg = req.userOrg; orgId = userOrg?.orgId; actorType = "user"; actor = user.username; actorId = user.userId; } const apiKey = req.apiKey; if (apiKey) { const apiKeyOrg = req.apiKeyOrg; orgId = apiKeyOrg?.orgId; actorType = "apiKey"; actor = apiKey.name; actorId = apiKey.apiKeyId; } if (!orgId) { logger.warn("logActionAudit: No organization context found"); return next(); } if (!actorType || !actor || !actorId) { logger.warn("logActionAudit: Incomplete actor information"); return next(); } const retentionDays = await getActionDays(orgId); if (retentionDays === 0) { // do not log return next(); } const timestamp = Math.floor(Date.now() / 1000); let metadata = null; if (req.params) { metadata = JSON.stringify(req.params); } await logsDb.insert(actionAuditLog).values({ timestamp, orgId, actorType, actor, actorId, action, metadata }); return next(); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying logging action" ) ); } }; } ================================================ FILE: server/private/middlewares/verifyCertificateAccess.ts ================================================ /* * 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 { db, domainNamespaces } from "@server/db"; import { certificates } from "@server/db"; import { domains, orgDomains } from "@server/db"; import { eq, and } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; export async function verifyCertificateAccess( req: Request, res: Response, next: NextFunction ) { try { // Assume user/org access is already verified const orgId = req.params.orgId; const certId = req.params.certId || req.body?.certId || req.query?.certId; let domainId = req.params.domainId || req.body?.domainId || req.query?.domainId; if (!orgId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") ); } if (!domainId) { if (!certId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Must provide either certId or domainId" ) ); } // Get the certificate and its domainId const [cert] = await db .select() .from(certificates) .where(eq(certificates.certId, Number(certId))) .limit(1); if (!cert) { return next( createHttpError( HttpCode.NOT_FOUND, `Certificate with ID ${certId} not found` ) ); } domainId = cert.domainId; if (!domainId) { return next( createHttpError( HttpCode.NOT_FOUND, `Certificate with ID ${certId} does not have a domain` ) ); } } if (!domainId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Must provide either certId or domainId" ) ); } // Check if the domain is a namespace domain const [namespaceDomain] = await db .select() .from(domainNamespaces) .where(eq(domainNamespaces.domainId, domainId)) .limit(1); if (namespaceDomain) { // If it's a namespace domain, we can skip the org check return next(); } // Check if the domain is associated with the org const [orgDomain] = await db .select() .from(orgDomains) .where( and( eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId) ) ) .limit(1); if (!orgDomain) { return next( createHttpError( HttpCode.FORBIDDEN, "Organization does not have access to this certificate" ) ); } return next(); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying certificate access" ) ); } } export default verifyCertificateAccess; ================================================ FILE: server/private/middlewares/verifyIdpAccess.ts ================================================ /* * 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 { userOrgs, db, idp, idpOrg } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyIdpAccess( req: Request, res: Response, next: NextFunction ) { try { const userId = req.user!.userId; const idpId = req.params.idpId || req.body.idpId || req.query.idpId; const orgId = req.params.orgId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (!orgId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") ); } if (!idpId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") ); } const [idpRes] = await db .select() .from(idp) .innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId)) .where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId))) .limit(1); if (!idpRes || !idpRes.idp || !idpRes.idpOrg) { return next( createHttpError( HttpCode.NOT_FOUND, `IdP with ID ${idpId} not found for organization ${orgId}` ) ); } if (!req.userOrg) { const userOrgRole = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, idpRes.idpOrg.orgId) ) ) .limit(1); req.userOrg = userOrgRole[0]; } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying idp access" ) ); } } ================================================ FILE: server/private/middlewares/verifyLoginPageAccess.ts ================================================ /* * 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 { userOrgs, db, loginPageOrg } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyLoginPageAccess( req: Request, res: Response, next: NextFunction ) { try { const userId = req.user!.userId; const loginPageId = req.params.loginPageId || req.body.loginPageId || req.query.loginPageId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } if (!loginPageId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid login page ID") ); } const loginPageOrgs = await db .select({ orgId: loginPageOrg.orgId }) .from(loginPageOrg) .where(eq(loginPageOrg.loginPageId, loginPageId)); const orgIds = loginPageOrgs.map((lpo) => lpo.orgId); const existingUserOrgs = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), inArray(userOrgs.orgId, orgIds) ) ); if (existingUserOrgs.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Login page with ID ${loginPageId} not found for user's organizations` ) ); } return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying login page access" ) ); } } ================================================ FILE: server/private/middlewares/verifyRemoteExitNode.ts ================================================ /* * 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 { NextFunction, Response } from "express"; import ErrorResponse from "@server/types/ErrorResponse"; import config from "@server/lib/config"; import { unauthorized } from "@server/auth/unauthorizedResponse"; import logger from "@server/logger"; import { validateRemoteExitNodeSessionToken } from "#private/auth/sessions/remoteExitNode"; export const verifySessionRemoteExitNodeMiddleware = async ( req: any, res: Response, next: NextFunction ) => { // get the token from the auth header const token = req.headers["authorization"]?.split(" ")[1] || ""; const { session, remoteExitNode } = await validateRemoteExitNodeSessionToken(token); if (!session || !remoteExitNode) { if (config.getRawConfig().app.log_failed_attempts) { logger.info(`Remote exit node session not found. IP: ${req.ip}.`); } return next(unauthorized()); } // const existingUser = await db // .select() // .from(users) // .where(eq(users.userId, user.userId)); // if (!existingUser || !existingUser[0]) { // if (config.getRawConfig().app.log_failed_attempts) { // logger.info(`User session not found. IP: ${req.ip}.`); // } // return next( // createHttpError(HttpCode.BAD_REQUEST, "User does not exist") // ); // } req.session = session; req.remoteExitNode = remoteExitNode; next(); }; ================================================ FILE: server/private/middlewares/verifyRemoteExitNodeAccess.ts ================================================ /* * 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 { db, exitNodeOrgs, exitNodes, remoteExitNodes } from "@server/db"; import { sites, userOrgs, userSites, roleSites, roles } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; export async function verifyRemoteExitNodeAccess( req: Request, res: Response, next: NextFunction ) { const userId = req.user!.userId; // Assuming you have user information in the request const orgId = req.params.orgId; const remoteExitNodeId = req.params.remoteExitNodeId || req.body.remoteExitNodeId || req.query.remoteExitNodeId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } try { const [remoteExitNode] = await db .select() .from(remoteExitNodes) .where(and(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))); if (!remoteExitNode) { return next( createHttpError( HttpCode.NOT_FOUND, `Remote exit node with ID ${remoteExitNodeId} not found` ) ); } if (!remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Remote exit node with ID ${remoteExitNodeId} does not have an exit node ID` ) ); } const [exitNodeOrg] = await db .select() .from(exitNodeOrgs) .where( and( eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId), eq(exitNodeOrgs.orgId, orgId) ) ); if (!exitNodeOrg) { return next( createHttpError( HttpCode.NOT_FOUND, `Remote exit node with ID ${remoteExitNodeId} not found in organization ${orgId}` ) ); } if (!req.userOrg) { // Get user's role ID in the organization const userOrgRole = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, exitNodeOrg.orgId) ) ) .limit(1); req.userOrg = userOrgRole[0]; } if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } const userOrgRoleId = req.userOrg.roleId; req.userOrgRoleId = userOrgRoleId; return next(); } catch (error) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying remote exit node access" ) ); } } ================================================ FILE: server/private/middlewares/verifySubscription.ts ================================================ /* * 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 createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { build } from "@server/build"; import { getOrgTierData } from "#private/lib/billing"; import { Tier } from "@server/types/Tiers"; export function verifyValidSubscription(tiers: Tier[]) { return async function ( req: Request, res: Response, next: NextFunction ): Promise { try { if (build != "saas") { return next(); } const orgId = req.params.orgId || req.body.orgId || req.query.orgId || req.userOrgId; if (!orgId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Organization ID is required to verify subscription" ) ); } const { tier, active } = await getOrgTierData(orgId); const isTier = tiers.includes(tier as Tier); if (!active) { return next( createHttpError( HttpCode.FORBIDDEN, "Organization does not have an active subscription" ) ); } if (!isTier) { return next( createHttpError( HttpCode.FORBIDDEN, "Organization subscription tier does not have access to this feature" ) ); } return next(); } catch (e) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying subscription" ) ); } }; } ================================================ FILE: server/private/middlewares/verifyValidLicense.ts ================================================ /* * 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 createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import license from "#private/license/license"; import { build } from "@server/build"; export async function verifyValidLicense( req: Request, res: Response, next: NextFunction ) { try { if (build != "enterprise") { return next(); } const unlocked = await license.isUnlocked(); if (!unlocked) { return next( createHttpError(HttpCode.FORBIDDEN, "License is not valid") ); } return next(); } catch (e) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Error verifying license" ) ); } } ================================================ FILE: server/private/routers/approvals/countApprovals.ts ================================================ /* * 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 logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { z } from "zod"; 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, inArray } from "drizzle-orm"; import response from "@server/lib/response"; const paramsSchema = z.strictObject({ orgId: z.string() }); const querySchema = z.strictObject({ approvalState: z .enum(["pending", "approved", "denied", "all"]) .optional() .default("all") .catch("all") }); export type CountApprovalsResponse = { count: number; }; export async function countApprovals( req: Request, res: Response, next: NextFunction ) { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { approvalState } = parsedQuery.data; const { orgId } = parsedParams.data; let state: Array = []; switch (approvalState) { case "pending": state = ["pending"]; break; case "approved": state = ["approved"]; break; case "denied": state = ["denied"]; break; default: state = ["approved", "denied", "pending"]; } const [{ count }] = await db .select({ count: sql`count(*)` }) .from(approvals) .where( and( eq(approvals.orgId, orgId), inArray(approvals.decision, state) ) ); return response(res, { data: { count }, success: true, error: false, message: "Approval count retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/approvals/index.ts ================================================ /* * 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 "./listApprovals"; export * from "./processPendingApproval"; export * from "./countApprovals"; ================================================ FILE: server/private/routers/approvals/listApprovals.ts ================================================ /* * 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 logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import type { Request, Response, NextFunction } from "express"; import { build } from "@server/build"; import { approvals, clients, db, users, olms, currentFingerprint, type Approval } from "@server/db"; import { eq, isNull, sql, not, and, desc, gte, lte } from "drizzle-orm"; import response from "@server/lib/response"; import { getUserDeviceName } from "@server/db/names"; const paramsSchema = z.strictObject({ orgId: z.string() }); const querySchema = z.strictObject({ limit: z.coerce .number() // for prettier formatting .int() .positive() .optional() .catch(20) .default(20), cursorPending: z.coerce // pending cursor .number() .int() .max(1) // 0 means non pending .min(0) // 1 means pending .optional() .catch(undefined), cursorTimestamp: z.coerce .number() .int() .positive() .optional() .catch(undefined), approvalState: z .enum(["pending", "approved", "denied", "all"]) .optional() .default("all") .catch("all"), clientId: z .string() .optional() .transform((val) => (val ? Number(val) : undefined)) .pipe(z.number().int().positive().optional()) }); async function queryApprovals({ orgId, limit, approvalState, cursorPending, cursorTimestamp, clientId }: { orgId: string; limit: number; approvalState: z.infer["approvalState"]; cursorPending?: number; cursorTimestamp?: number; clientId?: number; }) { let state: Array = []; switch (approvalState) { case "pending": state = ["pending"]; break; case "approved": state = ["approved"]; break; case "denied": state = ["denied"]; break; default: 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, orgId: approvals.orgId, clientId: approvals.clientId, decision: approvals.decision, type: approvals.type, user: { name: users.name, userId: users.userId, username: users.username, email: users.email }, clientName: clients.name, niceId: clients.niceId, deviceModel: currentFingerprint.deviceModel, fingerprintPlatform: currentFingerprint.platform, fingerprintOsVersion: currentFingerprint.osVersion, fingerprintKernelVersion: currentFingerprint.kernelVersion, fingerprintArch: currentFingerprint.arch, fingerprintSerialNumber: currentFingerprint.serialNumber, fingerprintUsername: currentFingerprint.username, fingerprintHostname: currentFingerprint.hostname, timestamp: approvals.timestamp }) .from(approvals) .innerJoin(users, and(eq(approvals.userId, users.userId))) .leftJoin( clients, and( eq(approvals.clientId, clients.clientId), not(isNull(clients.userId)) // only user devices ) ) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)) .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 const approvalsList = res.slice(0, limit).map((approval) => { const model = approval.deviceModel || null; const deviceName = approval.clientName ? getUserDeviceName(model, approval.clientName) : null; // Build fingerprint object if any fingerprint data exists const hasFingerprintData = approval.fingerprintPlatform || approval.fingerprintOsVersion || approval.fingerprintKernelVersion || approval.fingerprintArch || approval.fingerprintSerialNumber || approval.fingerprintUsername || approval.fingerprintHostname || approval.deviceModel; 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 } : null; const { clientName, deviceModel, fingerprintPlatform, fingerprintOsVersion, fingerprintKernelVersion, fingerprintArch, fingerprintSerialNumber, fingerprintUsername, fingerprintHostname, ...rest } = approval; return { ...rest, deviceName, fingerprint, 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> >["approvalsList"]; pagination: { total: number; limit: number; cursorPending: number | null; cursorTimestamp: number | null; }; }; export async function listApprovals( req: Request, res: Response, next: NextFunction ) { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { limit, cursorPending, cursorTimestamp, approvalState, clientId } = parsedQuery.data; const { orgId } = parsedParams.data; const { approvalsList, nextCursorPending, nextCursorTimestamp } = await queryApprovals({ orgId: orgId.toString(), limit, cursorPending, cursorTimestamp, approvalState, clientId }); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(approvals); return response(res, { data: { approvals: approvalsList, pagination: { total: count, limit, cursorPending: nextCursorPending, cursorTimestamp: nextCursorTimestamp } }, success: true, error: false, message: "Approvals retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/approvals/processPendingApproval.ts ================================================ /* * 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 logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { approvals, clients, db, orgs, type Approval } from "@server/db"; import response from "@server/lib/response"; import { and, eq, type InferInsertModel } from "drizzle-orm"; import type { NextFunction, Request, Response } from "express"; const paramsSchema = z.strictObject({ orgId: z.string(), approvalId: z.string().transform(Number).pipe(z.int().positive()) }); const bodySchema = z.strictObject({ decision: z.enum(["approved", "denied"]) }); export type ProcessApprovalResponse = Approval; export async function processPendingApproval( req: Request, res: Response, next: NextFunction ) { 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, approvalId } = parsedParams.data; const updateData = parsedBody.data; const approval = await db .select() .from(approvals) .where( and( eq(approvals.approvalId, approvalId), eq(approvals.decision, "pending") ) ) .innerJoin(orgs, eq(approvals.orgId, approvals.orgId)) .limit(1); if (approval.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Pending Approval with ID ${approvalId} not found` ) ); } const [updatedApproval] = await db .update(approvals) .set(updateData) .where(eq(approvals.approvalId, approvalId)) .returning(); // Update user device approval state too if ( updatedApproval.type === "user_device" && updatedApproval.clientId ) { const updateDataBody: Partial> = { approvalState: updateData.decision }; if (updateData.decision === "denied") { updateDataBody.blocked = true; } await db .update(clients) .set(updateDataBody) .where(eq(clients.clientId, updatedApproval.clientId)); } return response(res, { data: updatedApproval, success: true, error: false, message: "Approval updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/auditLogs/exportAccessAuditLog.ts ================================================ /* * 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 { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; import { OpenAPITags } from "@server/openApi"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { queryAccessAuditLogsParams, queryAccessAuditLogsQuery, queryAccess, countAccessQuery } from "./queryAccessAuditLog"; import { generateCSV } from "@server/routers/auditLogs/generateCSV"; import { MAX_EXPORT_LIMIT } from "@server/routers/auditLogs"; registry.registerPath({ method: "get", path: "/org/{orgId}/logs/access/export", description: "Export the access audit log for an organization as CSV", tags: [OpenAPITags.Logs], request: { query: queryAccessAuditLogsQuery, params: queryAccessAuditLogsParams }, responses: {} }); export async function exportAccessAuditLogs( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const data = { ...parsedQuery.data, ...parsedParams.data }; const [{ count }] = await countAccessQuery(data); if (count > MAX_EXPORT_LIMIT) { return next( createHttpError( HttpCode.BAD_REQUEST, `Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.` ) ); } const baseQuery = queryAccess(data); const log = await baseQuery.limit(data.limit).offset(data.offset); const csvData = generateCSV(log); res.setHeader("Content-Type", "text/csv"); res.setHeader( "Content-Disposition", `attachment; filename="access-audit-logs-${data.orgId}-${Date.now()}.csv"` ); return res.send(csvData); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/auditLogs/exportActionAuditLog.ts ================================================ /* * 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 { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; import { OpenAPITags } from "@server/openApi"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { queryActionAuditLogsParams, queryActionAuditLogsQuery, queryAction, countActionQuery } from "./queryActionAuditLog"; import { generateCSV } from "@server/routers/auditLogs/generateCSV"; import { MAX_EXPORT_LIMIT } from "@server/routers/auditLogs"; registry.registerPath({ method: "get", path: "/org/{orgId}/logs/action/export", description: "Export the action audit log for an organization as CSV", tags: [OpenAPITags.Logs], request: { query: queryActionAuditLogsQuery, params: queryActionAuditLogsParams }, responses: {} }); export async function exportActionAuditLogs( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const parsedParams = queryActionAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const data = { ...parsedQuery.data, ...parsedParams.data }; const [{ count }] = await countActionQuery(data); if (count > MAX_EXPORT_LIMIT) { return next( createHttpError( HttpCode.BAD_REQUEST, `Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.` ) ); } const baseQuery = queryAction(data); const log = await baseQuery.limit(data.limit).offset(data.offset); const csvData = generateCSV(log); res.setHeader("Content-Type", "text/csv"); res.setHeader( "Content-Disposition", `attachment; filename="action-audit-logs-${data.orgId}-${Date.now()}.csv"` ); return res.send(csvData); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/auditLogs/index.ts ================================================ /* * 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 "./queryActionAuditLog"; export * from "./exportActionAuditLog"; export * from "./queryAccessAuditLog"; export * from "./exportAccessAuditLog"; ================================================ FILE: server/private/routers/auditLogs/queryAccessAuditLog.ts ================================================ /* * 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 { accessAuditLog, logsDb, resources, db, primaryDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types"; import response from "@server/lib/response"; import logger from "@server/logger"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; export const queryAccessAuditLogsQuery = z.object({ // iso string just validate its a parseable date timeStart: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .prefault(() => getSevenDaysAgo().toISOString()) .openapi({ type: "string", format: "date-time", description: "Start time as ISO date string (defaults to 7 days ago)" }), timeEnd: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() .prefault(() => new Date().toISOString()) .openapi({ type: "string", format: "date-time", description: "End time as ISO date string (defaults to current time)" }), action: z .union([z.boolean(), z.string()]) .transform((val) => (typeof val === "string" ? val === "true" : val)) .optional(), actorType: z.string().optional(), actorId: z.string().optional(), resourceId: z .string() .optional() .transform(Number) .pipe(z.int().positive()) .optional(), actor: z.string().optional(), type: z.string().optional(), location: z.string().optional(), limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); export const queryAccessAuditLogsParams = z.object({ orgId: z.string() }); export const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge( queryAccessAuditLogsParams ); type Q = z.infer; function getWhere(data: Q) { return and( gt(accessAuditLog.timestamp, data.timeStart), lt(accessAuditLog.timestamp, data.timeEnd), eq(accessAuditLog.orgId, data.orgId), data.resourceId ? eq(accessAuditLog.resourceId, data.resourceId) : undefined, data.actor ? eq(accessAuditLog.actor, data.actor) : undefined, data.actorType ? eq(accessAuditLog.actorType, data.actorType) : undefined, data.actorId ? eq(accessAuditLog.actorId, data.actorId) : undefined, data.location ? eq(accessAuditLog.location, data.location) : undefined, data.type ? eq(accessAuditLog.type, data.type) : undefined, data.action !== undefined ? eq(accessAuditLog.action, data.action) : undefined ); } export function queryAccess(data: Q) { return logsDb .select({ orgId: accessAuditLog.orgId, action: accessAuditLog.action, actorType: accessAuditLog.actorType, actorId: accessAuditLog.actorId, resourceId: accessAuditLog.resourceId, ip: accessAuditLog.ip, location: accessAuditLog.location, userAgent: accessAuditLog.userAgent, metadata: accessAuditLog.metadata, type: accessAuditLog.type, timestamp: accessAuditLog.timestamp, actor: accessAuditLog.actor }) .from(accessAuditLog) .where(getWhere(data)) .orderBy(desc(accessAuditLog.timestamp), desc(accessAuditLog.id)); } async function enrichWithResourceDetails(logs: Awaited>) { // If logs database is the same as main database, we can do a join // Otherwise, we need to fetch resource details separately const resourceIds = logs .map(log => log.resourceId) .filter((id): id is number => id !== null && id !== undefined); if (resourceIds.length === 0) { return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); } // Fetch resource details from main database const resourceDetails = await primaryDb .select({ resourceId: resources.resourceId, name: resources.name, niceId: resources.niceId }) .from(resources) .where(inArray(resources.resourceId, resourceIds)); // Create a map for quick lookup const resourceMap = new Map( resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }]) ); // Enrich logs with resource details return logs.map(log => ({ ...log, resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null, resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null })); } export function countAccessQuery(data: Q) { const countQuery = logsDb .select({ count: count() }) .from(accessAuditLog) .where(getWhere(data)); return countQuery; } async function queryUniqueFilterAttributes( timeStart: number, timeEnd: number, orgId: string ) { const baseConditions = and( gt(accessAuditLog.timestamp, timeStart), lt(accessAuditLog.timestamp, timeEnd), eq(accessAuditLog.orgId, orgId) ); // Get unique actors const uniqueActors = await logsDb .selectDistinct({ actor: accessAuditLog.actor }) .from(accessAuditLog) .where(baseConditions); // Get unique locations const uniqueLocations = await logsDb .selectDistinct({ locations: accessAuditLog.location }) .from(accessAuditLog) .where(baseConditions); // Get unique resources with names const uniqueResources = await logsDb .selectDistinct({ id: accessAuditLog.resourceId }) .from(accessAuditLog) .where(baseConditions); // Fetch resource names from main database for the unique resource IDs const resourceIds = uniqueResources .map(row => row.id) .filter((id): id is number => id !== null); let resourcesWithNames: Array<{ id: number; name: string | null }> = []; if (resourceIds.length > 0) { const resourceDetails = await primaryDb .select({ resourceId: resources.resourceId, name: resources.name }) .from(resources) .where(inArray(resources.resourceId, resourceIds)); resourcesWithNames = resourceDetails.map(r => ({ id: r.resourceId, name: r.name })); } return { actors: uniqueActors .map((row) => row.actor) .filter((actor): actor is string => actor !== null), resources: resourcesWithNames, locations: uniqueLocations .map((row) => row.locations) .filter((location): location is string => location !== null) }; } registry.registerPath({ method: "get", path: "/org/{orgId}/logs/access", description: "Query the access audit log for an organization", tags: [OpenAPITags.Logs], request: { query: queryAccessAuditLogsQuery, params: queryAccessAuditLogsParams }, responses: {} }); export async function queryAccessAuditLogs( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const data = { ...parsedQuery.data, ...parsedParams.data }; const baseQuery = queryAccess(data); const logsRaw = await baseQuery.limit(data.limit).offset(data.offset); // Enrich with resource details (handles cross-database scenario) const log = await enrichWithResourceDetails(logsRaw); const totalCountResult = await countAccessQuery(data); const totalCount = totalCountResult[0].count; const filterAttributes = await queryUniqueFilterAttributes( data.timeStart, data.timeEnd, data.orgId ); return response(res, { data: { log: log, pagination: { total: totalCount, limit: data.limit, offset: data.offset }, filterAttributes }, success: true, error: false, message: "Access audit logs retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/auditLogs/queryActionAuditLog.ts ================================================ /* * 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 { actionAuditLog, logsDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; import { eq, gt, lt, and, count, desc } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types"; import response from "@server/lib/response"; import logger from "@server/logger"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; export const queryActionAuditLogsQuery = z.object({ // iso string just validate its a parseable date timeStart: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .prefault(() => getSevenDaysAgo().toISOString()) .openapi({ type: "string", format: "date-time", description: "Start time as ISO date string (defaults to 7 days ago)" }), timeEnd: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() .prefault(() => new Date().toISOString()) .openapi({ type: "string", format: "date-time", description: "End time as ISO date string (defaults to current time)" }), action: z.string().optional(), actorType: z.string().optional(), actorId: z.string().optional(), actor: z.string().optional(), limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); export const queryActionAuditLogsParams = z.object({ orgId: z.string() }); export const queryActionAuditLogsCombined = queryActionAuditLogsQuery.merge( queryActionAuditLogsParams ); type Q = z.infer; function getWhere(data: Q) { return and( gt(actionAuditLog.timestamp, data.timeStart), lt(actionAuditLog.timestamp, data.timeEnd), eq(actionAuditLog.orgId, data.orgId), data.actor ? eq(actionAuditLog.actor, data.actor) : undefined, data.actorType ? eq(actionAuditLog.actorType, data.actorType) : undefined, data.actorId ? eq(actionAuditLog.actorId, data.actorId) : undefined, data.action ? eq(actionAuditLog.action, data.action) : undefined ); } export function queryAction(data: Q) { return logsDb .select({ orgId: actionAuditLog.orgId, action: actionAuditLog.action, actorType: actionAuditLog.actorType, metadata: actionAuditLog.metadata, actorId: actionAuditLog.actorId, timestamp: actionAuditLog.timestamp, actor: actionAuditLog.actor }) .from(actionAuditLog) .where(getWhere(data)) .orderBy(desc(actionAuditLog.timestamp), desc(actionAuditLog.id)); } export function countActionQuery(data: Q) { const countQuery = logsDb .select({ count: count() }) .from(actionAuditLog) .where(getWhere(data)); return countQuery; } async function queryUniqueFilterAttributes( timeStart: number, timeEnd: number, orgId: string ) { const baseConditions = and( gt(actionAuditLog.timestamp, timeStart), lt(actionAuditLog.timestamp, timeEnd), eq(actionAuditLog.orgId, orgId) ); // Get unique actors const uniqueActors = await logsDb .selectDistinct({ actor: actionAuditLog.actor }) .from(actionAuditLog) .where(baseConditions); const uniqueActions = await logsDb .selectDistinct({ action: actionAuditLog.action }) .from(actionAuditLog) .where(baseConditions); return { actors: uniqueActors .map((row) => row.actor) .filter((actor): actor is string => actor !== null), actions: uniqueActions .map((row) => row.action) .filter((action): action is string => action !== null) }; } registry.registerPath({ method: "get", path: "/org/{orgId}/logs/action", description: "Query the action audit log for an organization", tags: [OpenAPITags.Logs], request: { query: queryActionAuditLogsQuery, params: queryActionAuditLogsParams }, responses: {} }); export async function queryActionAuditLogs( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const parsedParams = queryActionAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const data = { ...parsedQuery.data, ...parsedParams.data }; const baseQuery = queryAction(data); const log = await baseQuery.limit(data.limit).offset(data.offset); const totalCountResult = await countActionQuery(data); const totalCount = totalCountResult[0].count; const filterAttributes = await queryUniqueFilterAttributes( data.timeStart, data.timeEnd, data.orgId ); return response(res, { data: { log: log, pagination: { total: totalCount, limit: data.limit, offset: data.offset }, filterAttributes }, success: true, error: false, message: "Action audit logs retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/auth/getSessionTransferToken.ts ================================================ /* * 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, sessionTransferToken } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { generateSessionToken, SESSION_COOKIE_NAME } from "@server/auth/sessions/app"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { response } from "@server/lib/response"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; const paramsSchema = z.strictObject({}); export type GetSessionTransferTokenRenponse = { token: string; }; export async function getSessionTransferToken( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { user, session } = req; if (!user || !session) { return next(createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized")); } const tokenRaw = generateSessionToken(); const token = encodeHexLowerCase( sha256(new TextEncoder().encode(tokenRaw)) ); const rawSessionId = req.cookies[SESSION_COOKIE_NAME]; if (!rawSessionId) { return next(createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized")); } const encryptedSession = encrypt( rawSessionId, config.getRawConfig().server.secret! ); await db.insert(sessionTransferToken).values({ encryptedSession, token, sessionId: session.sessionId, expiresAt: Date.now() + 30 * 1000 // Token valid for 30 seconds }); return response(res, { data: { token: tokenRaw }, success: true, error: false, message: "Transfer token created successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/auth/index.ts ================================================ /* * 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 "./transferSession"; export * from "./getSessionTransferToken"; ================================================ FILE: server/private/routers/auth/transferSession.ts ================================================ /* * 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 HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { sessions, sessionTransferToken } from "@server/db"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; import { response } from "@server/lib/response"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { serializeSessionCookie } from "@server/auth/sessions/app"; import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import { TransferSessionResponse } from "@server/routers/auth/types"; const bodySchema = z.object({ token: z.string() }); export type TransferSessionBodySchema = z.infer; export async function transferSession( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } try { const { token } = parsedBody.data; const tokenRaw = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const [existing] = await db .select() .from(sessionTransferToken) .where(eq(sessionTransferToken.token, tokenRaw)) .innerJoin( sessions, eq(sessions.sessionId, sessionTransferToken.sessionId) ) .limit(1); if (!existing) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid transfer token") ); } const transferToken = existing.sessionTransferToken; const session = existing.session; if (!transferToken) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid transfer token") ); } await db .delete(sessionTransferToken) .where(eq(sessionTransferToken.token, tokenRaw)); if (Date.now() > transferToken.expiresAt) { return next( createHttpError(HttpCode.BAD_REQUEST, "Transfer token expired") ); } const rawSession = decrypt( transferToken.encryptedSession, config.getRawConfig().server.secret! ); const isSecure = req.protocol === "https"; const cookie = serializeSessionCookie( rawSession, isSecure, new Date(session.expiresAt) ); res.appendHeader("Set-Cookie", cookie); return response(res, { data: { valid: true, cookie }, success: true, error: false, message: "Session exchanged successfully", status: HttpCode.OK }); } catch (e) { console.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to exchange session" ) ); } } ================================================ FILE: server/private/routers/billing/changeTier.ts ================================================ /* * 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 { customers, db, subscriptions, subscriptionItems } from "@server/db"; import { eq, and, or } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import stripe from "#private/lib/stripe"; import { getTier1FeaturePriceSet, getTier3FeaturePriceSet, getTier2FeaturePriceSet, FeatureId, type FeaturePriceSet } from "@server/lib/billing"; import { getLineItems } from "@server/lib/billing/getLineItems"; const changeTierSchema = z.strictObject({ orgId: z.string() }); const changeTierBodySchema = z.strictObject({ tier: z.enum(["tier1", "tier2", "tier3"]) }); export async function changeTier( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = changeTierSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const parsedBody = changeTierBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { tier } = parsedBody.data; // Get the customer for this org const [customer] = await db .select() .from(customers) .where(eq(customers.orgId, orgId)) .limit(1); if (!customer) { return next( createHttpError( HttpCode.BAD_REQUEST, "No customer found for this organization" ) ); } // Get the active subscription for this customer const [subscription] = await db .select() .from(subscriptions) .where( and( eq(subscriptions.customerId, customer.customerId), eq(subscriptions.status, "active"), or( eq(subscriptions.type, "tier1"), eq(subscriptions.type, "tier2"), eq(subscriptions.type, "tier3") ) ) ) .limit(1); if (!subscription) { return next( createHttpError( HttpCode.BAD_REQUEST, "No active subscription found for this organization" ) ); } // Get the target tier's price set let targetPriceSet: FeaturePriceSet; if (tier === "tier1") { targetPriceSet = getTier1FeaturePriceSet(); } else if (tier === "tier2") { targetPriceSet = getTier2FeaturePriceSet(); } else if (tier === "tier3") { targetPriceSet = getTier3FeaturePriceSet(); } else { return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier")); } // Get current subscription items from our database const currentItems = await db .select() .from(subscriptionItems) .where( eq( subscriptionItems.subscriptionId, subscription.subscriptionId ) ); if (currentItems.length === 0) { return next( createHttpError( HttpCode.BAD_REQUEST, "No subscription items found" ) ); } // Retrieve the full subscription from Stripe to get item IDs const stripeSubscription = await stripe!.subscriptions.retrieve( subscription.subscriptionId ); // Determine if we're switching between different products // tier1 uses TIER1 product, tier2/tier3 use USERS product const currentTier = subscription.type; const switchingProducts = (currentTier === "tier1" && (tier === "tier2" || tier === "tier3")) || ((currentTier === "tier2" || currentTier === "tier3") && tier === "tier1"); let updatedSubscription; if (switchingProducts) { // When switching between different products, we need to: // 1. Delete old subscription items // 2. Add new subscription items logger.info( `Switching products from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}` ); // Build array to delete all existing items and add new ones const itemsToUpdate: any[] = []; // Mark all existing items for deletion for (const stripeItem of stripeSubscription.items.data) { itemsToUpdate.push({ id: stripeItem.id, deleted: true }); } // Add new items for the target tier const newLineItems = await getLineItems(targetPriceSet, orgId); for (const lineItem of newLineItems) { itemsToUpdate.push(lineItem); } updatedSubscription = await stripe!.subscriptions.update( subscription.subscriptionId, { items: itemsToUpdate, proration_behavior: "create_prorations" } ); } else { // Same product, different price tier (tier2 <-> tier3) // We can simply update the price logger.info( `Updating price from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}` ); const itemsToUpdate = stripeSubscription.items.data.map( (stripeItem) => { // Find the corresponding item in our database const dbItem = currentItems.find( (item) => item.priceId === stripeItem.price.id ); if (!dbItem) { // Keep the existing item unchanged if we can't find it return { id: stripeItem.id, price: stripeItem.price.id, quantity: stripeItem.quantity }; } // Map to the corresponding feature in the new tier const newPriceId = targetPriceSet[FeatureId.USERS]; if (newPriceId) { return { id: stripeItem.id, price: newPriceId, quantity: stripeItem.quantity }; } // If no mapping found, keep existing return { id: stripeItem.id, price: stripeItem.price.id, quantity: stripeItem.quantity }; } ); updatedSubscription = await stripe!.subscriptions.update( subscription.subscriptionId, { items: itemsToUpdate, proration_behavior: "create_prorations" } ); } logger.info( `Successfully changed tier to ${tier} for org ${orgId}, subscription ${subscription.subscriptionId}` ); return response<{ subscriptionId: string; newTier: string }>(res, { data: { subscriptionId: updatedSubscription.id, newTier: tier }, success: true, error: false, message: "Tier change successful", status: HttpCode.OK }); } catch (error) { logger.error("Error changing tier:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred while changing tier" ) ); } } ================================================ FILE: server/private/routers/billing/createCheckoutSession.ts ================================================ /* * 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 { customers, db } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import config from "@server/lib/config"; import { fromError } from "zod-validation-error"; import stripe from "#private/lib/stripe"; import { getTier1FeaturePriceSet, getTier3FeaturePriceSet, getTier2FeaturePriceSet } from "@server/lib/billing"; import { getLineItems } from "@server/lib/billing/getLineItems"; import Stripe from "stripe"; const createCheckoutSessionSchema = z.strictObject({ orgId: z.string() }); const createCheckoutSessionBodySchema = z.strictObject({ tier: z.enum(["tier1", "tier2", "tier3"]) }); export async function createCheckoutSession( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = createCheckoutSessionSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const parsedBody = createCheckoutSessionBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { tier } = parsedBody.data; // check if we already have a customer for this org const [customer] = await db .select() .from(customers) .where(eq(customers.orgId, orgId)) .limit(1); // If we don't have a customer, create one if (!customer) { // error return next( createHttpError( HttpCode.BAD_REQUEST, "No customer found for this organization" ) ); } let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[]; if (tier === "tier1") { lineItems = await getLineItems(getTier1FeaturePriceSet(), orgId); } else if (tier === "tier2") { lineItems = await getLineItems(getTier2FeaturePriceSet(), orgId); } else if (tier === "tier3") { lineItems = await getLineItems(getTier3FeaturePriceSet(), orgId); } else { return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid plan")); } logger.debug(`Line items: ${JSON.stringify(lineItems)}`); const session = await stripe!.checkout.sessions.create({ client_reference_id: orgId, // So we can look it up the org later on the webhook billing_address_collection: "required", line_items: lineItems, customer: customer.customerId, mode: "subscription", allow_promotion_codes: true, success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?canceled=true` }); return response(res, { data: session.url, success: true, error: false, message: "Checkout session created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/billing/createPortalSession.ts ================================================ /* * 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 { account, customers, db } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import config from "@server/lib/config"; import { fromError } from "zod-validation-error"; import stripe from "#private/lib/stripe"; const createPortalSessionSchema = z.strictObject({ orgId: z.string() }); export async function createPortalSession( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = createPortalSessionSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; // check if we already have a customer for this org const [customer] = await db .select() .from(customers) .where(eq(customers.orgId, orgId)) .limit(1); let customerId: string; // If we don't have a customer, create one if (!customer) { // error return next( createHttpError( HttpCode.BAD_REQUEST, "No customer found for this organization" ) ); } else { // If we have a customer, use the existing customer ID customerId = customer.customerId; } const portalSession = await stripe!.billingPortal.sessions.create({ customer: customerId, return_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing` }); return response(res, { data: portalSession.url, success: true, error: false, message: "Organization created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/billing/featureLifecycle.ts ================================================ /* * 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 { 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, siteResources } from "@server/db"; import { eq } from "drizzle-orm"; /** * Get the maximum allowed retention days for a given tier * Returns null for enterprise tier (unlimited) */ function getMaxRetentionDaysForTier(tier: Tier | null): number | null { if (!tier) { return 3; // Free tier } switch (tier) { case "tier1": return 7; case "tier2": return 30; case "tier3": return 90; case "enterprise": return null; // No limit default: return 3; // Default to free tier limit } } /** * Cap retention days to the maximum allowed for the given tier */ async function capRetentionDays( orgId: string, tier: Tier | null ): Promise { const maxRetentionDays = getMaxRetentionDaysForTier(tier); // If there's no limit (enterprise tier), no capping needed if (maxRetentionDays === null) { logger.debug( `No retention day limit for org ${orgId} on tier ${tier || "free"}` ); return; } // Get current org settings const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); if (!org) { logger.warn(`Org ${orgId} not found when capping retention days`); return; } const updates: Partial = {}; let needsUpdate = false; // Cap request log retention if it exceeds the limit if ( org.settingsLogRetentionDaysRequest !== null && org.settingsLogRetentionDaysRequest > maxRetentionDays ) { updates.settingsLogRetentionDaysRequest = maxRetentionDays; needsUpdate = true; logger.info( `Capping request log retention from ${org.settingsLogRetentionDaysRequest} to ${maxRetentionDays} days for org ${orgId}` ); } // Cap access log retention if it exceeds the limit if ( org.settingsLogRetentionDaysAccess !== null && org.settingsLogRetentionDaysAccess > maxRetentionDays ) { updates.settingsLogRetentionDaysAccess = maxRetentionDays; needsUpdate = true; logger.info( `Capping access log retention from ${org.settingsLogRetentionDaysAccess} to ${maxRetentionDays} days for org ${orgId}` ); } // Cap action log retention if it exceeds the limit if ( org.settingsLogRetentionDaysAction !== null && org.settingsLogRetentionDaysAction > maxRetentionDays ) { updates.settingsLogRetentionDaysAction = maxRetentionDays; needsUpdate = true; logger.info( `Capping action log retention from ${org.settingsLogRetentionDaysAction} to ${maxRetentionDays} days for org ${orgId}` ); } // Apply updates if needed if (needsUpdate) { 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}`); } } export async function handleTierChange( orgId: string, newTier: SubscriptionType | null, previousTier?: SubscriptionType | null ): Promise { logger.info( `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 { 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( `New tier is license for org ${orgId}, no feature lifecycle handling needed` ); return; } // If newTier is null, treat as free tier - disable all features if (newTier === null) { logger.info( `Org ${orgId} is reverting to free tier, disabling all paid features` ); // Cap retention days to free tier limits await capRetentionDays(orgId, null); // Disable all features in the tier matrix for (const [featureKey] of Object.entries(tierMatrix)) { const feature = featureKey as TierFeature; logger.info( `Feature ${feature} is not available in free tier for org ${orgId}. Disabling...` ); await disableFeature(orgId, feature); } logger.info( `Completed free tier feature lifecycle handling for org ${orgId}` ); return; } // Get the tier (cast as Tier since we've ruled out "license" and null) const tier = newTier as Tier; // Cap retention days to the new tier's limits await capRetentionDays(orgId, tier); // Check each feature in the tier matrix for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) { const feature = featureKey as TierFeature; const isFeatureAvailable = allowedTiers.includes(tier); if (!isFeatureAvailable) { logger.info( `Feature ${feature} is not available in tier ${tier} for org ${orgId}. Disabling...` ); await disableFeature(orgId, feature); } else { logger.debug( `Feature ${feature} is available in tier ${tier} for org ${orgId}` ); } } logger.info( `Completed tier change feature lifecycle handling for org ${orgId}` ); } async function disableFeature( orgId: string, feature: TierFeature ): Promise { try { switch (feature) { case TierFeature.OrgOidc: await disableOrgOidc(orgId); break; case TierFeature.LoginPageDomain: await disableLoginPageDomain(orgId); break; case TierFeature.DeviceApprovals: await disableDeviceApprovals(orgId); break; case TierFeature.LoginPageBranding: await disableLoginPageBranding(orgId); break; case TierFeature.LogExport: await disableLogExport(orgId); break; case TierFeature.AccessLogs: await disableAccessLogs(orgId); break; case TierFeature.ActionLogs: await disableActionLogs(orgId); break; case TierFeature.RotateCredentials: await disableRotateCredentials(orgId); break; case TierFeature.MaintencePage: await disableMaintencePage(orgId); break; case TierFeature.DevicePosture: await disableDevicePosture(orgId); break; case TierFeature.TwoFactorEnforcement: await disableTwoFactorEnforcement(orgId); break; case TierFeature.SessionDurationPolicies: await disableSessionDurationPolicies(orgId); break; case TierFeature.PasswordExpirationPolicies: await disablePasswordExpirationPolicies(orgId); break; case TierFeature.AutoProvisioning: await disableAutoProvisioning(orgId); break; case TierFeature.SshPam: await disableSshPam(orgId); break; default: logger.warn( `Unknown feature ${feature} for org ${orgId}, skipping` ); } logger.info( `Successfully disabled feature ${feature} for org ${orgId}` ); } catch (error) { logger.error( `Error disabling feature ${feature} for org ${orgId}:`, error ); throw error; } } async function disableOrgOidc(orgId: string): Promise {} async function disableDeviceApprovals(orgId: string): Promise { await db .update(roles) .set({ requireDeviceApproval: false }) .where(eq(roles.orgId, orgId)); logger.info(`Disabled device approvals on all roles for org ${orgId}`); } async function disableSshPam(orgId: string): Promise { logger.info( `Disabled SSH PAM options on all roles and site resources for org ${orgId}` ); } async function disableLoginPageBranding(orgId: string): Promise { const [existingBranding] = await db .select() .from(loginPageBrandingOrg) .where(eq(loginPageBrandingOrg.orgId, orgId)); if (existingBranding) { await db .delete(loginPageBranding) .where( eq( loginPageBranding.loginPageBrandingId, existingBranding.loginPageBrandingId ) ); logger.info(`Disabled login page branding for org ${orgId}`); } } async function disableLoginPageDomain(orgId: string): Promise { const [existingLoginPage] = await db .select() .from(loginPageOrg) .where(eq(loginPageOrg.orgId, orgId)) .innerJoin( loginPage, eq(loginPage.loginPageId, loginPageOrg.loginPageId) ); if (existingLoginPage) { await db.delete(loginPageOrg).where(eq(loginPageOrg.orgId, orgId)); await db .delete(loginPage) .where( eq( loginPage.loginPageId, existingLoginPage.loginPageOrg.loginPageId ) ); logger.info(`Disabled login page domain for org ${orgId}`); } } async function disableLogExport(orgId: string): Promise {} async function disableAccessLogs(orgId: string): Promise { await db .update(orgs) .set({ settingsLogRetentionDaysAccess: 0 }) .where(eq(orgs.orgId, orgId)); logger.info(`Disabled access logs for org ${orgId}`); } async function disableActionLogs(orgId: string): Promise { await db .update(orgs) .set({ settingsLogRetentionDaysAction: 0 }) .where(eq(orgs.orgId, orgId)); logger.info(`Disabled action logs for org ${orgId}`); } async function disableRotateCredentials(orgId: string): Promise {} async function disableMaintencePage(orgId: string): Promise { await db .update(resources) .set({ maintenanceModeEnabled: false }) .where(eq(resources.orgId, orgId)); logger.info(`Disabled maintenance page on all resources for org ${orgId}`); } async function disableDevicePosture(orgId: string): Promise {} async function disableTwoFactorEnforcement(orgId: string): Promise { await db .update(orgs) .set({ requireTwoFactor: false }) .where(eq(orgs.orgId, orgId)); logger.info(`Disabled two-factor enforcement for org ${orgId}`); } async function disableSessionDurationPolicies(orgId: string): Promise { await db .update(orgs) .set({ maxSessionLengthHours: null }) .where(eq(orgs.orgId, orgId)); logger.info(`Disabled session duration policies for org ${orgId}`); } async function disablePasswordExpirationPolicies(orgId: string): Promise { await db .update(orgs) .set({ passwordExpiryDays: null }) .where(eq(orgs.orgId, orgId)); logger.info(`Disabled password expiration policies for org ${orgId}`); } async function disableAutoProvisioning(orgId: string): Promise { // Get all IDP IDs for this org through the idpOrg join table const orgIdps = await db .select({ idpId: idpOrg.idpId }) .from(idpOrg) .where(eq(idpOrg.orgId, orgId)); // Update autoProvision to false for all IDPs in this org for (const { idpId } of orgIdps) { await db .update(idp) .set({ autoProvision: false }) .where(eq(idp.idpId, idpId)); } } ================================================ FILE: server/private/routers/billing/getOrgSubscriptions.ts ================================================ /* * 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 } from "@server/db"; import { Org, orgs } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { GetOrgSubscriptionResponse } from "@server/routers/billing/types"; import { usageService } from "@server/lib/billing/usageService"; import { build } from "@server/build"; // Import tables for billing import { customers, subscriptions, subscriptionItems, Subscription, SubscriptionItem } from "@server/db"; const getOrgSchema = z.strictObject({ orgId: z.string() }); export async function getOrgSubscriptions( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getOrgSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const { orgId } = parsedParams.data; let subscriptions = null; try { subscriptions = await getOrgSubscriptionsData(orgId); } catch (err) { if ((err as Error).message === "Not found") { return next( createHttpError( HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found` ) ); } throw err; } let limitsExceeded = false; if (build === "saas") { try { limitsExceeded = await usageService.checkLimitSet(orgId); } catch (err) { logger.error("Error checking limits for org %s: %s", orgId, err); } } return response(res, { data: { subscriptions, ...(build === "saas" ? { limitsExceeded } : {}) }, success: true, error: false, message: "Organization and subscription retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } export async function getOrgSubscriptionsData( orgId: string ): Promise> { const org = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (org.length === 0) { 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, billingOrgId)) .limit(1); const subscriptionsWithItems: Array<{ subscription: Subscription; items: SubscriptionItem[]; }> = []; if (customer.length > 0) { // Get all subscriptions for customer const subs = await db .select() .from(subscriptions) .where(eq(subscriptions.customerId, customer[0].customerId)); for (const subscription of subs) { // Get subscription items for each subscription const items = await db .select() .from(subscriptionItems) .where( eq( subscriptionItems.subscriptionId, subscription.subscriptionId ) ); subscriptionsWithItems.push({ subscription, items }); } } return subscriptionsWithItems; } ================================================ FILE: server/private/routers/billing/getOrgUsage.ts ================================================ /* * 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 } from "@server/db"; import { orgs } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { Limit, limits, Usage, usage } from "@server/db"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { GetOrgUsageResponse } from "@server/routers/billing/types"; const getOrgSchema = z.strictObject({ orgId: z.string() }); // registry.registerPath({ // method: "get", // path: "/org/{orgId}/billing/usage", // description: "Get an organization's billing usage", // tags: [OpenAPITags.Org], // request: { // params: getOrgSchema // }, // responses: {} // }); export async function getOrgUsage( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getOrgSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const { orgId } = parsedParams.data; const org = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (org.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found` ) ); } // Get usage for org const usageData = []; const sites = await usageService.getUsage(orgId, FeatureId.SITES); const users = await usageService.getUsage(orgId, FeatureId.USERS); const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS); const remoteExitNodes = await usageService.getUsage( orgId, FeatureId.REMOTE_EXIT_NODES ); const organizations = await usageService.getUsage( orgId, FeatureId.ORGINIZATIONS ); // const egressData = await usageService.getUsage( // orgId, // FeatureId.EGRESS_DATA_MB // ); if (sites) { usageData.push(sites); } if (users) { usageData.push(users); } // 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() .from(limits) .where(eq(limits.orgId, orgId)); return response(res, { data: { usage: usageData, limits: orgLimits }, success: true, error: false, message: "Organization usage retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/billing/hooks/getSubType.ts ================================================ /* * 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 { getLicensePriceSet, } from "@server/lib/billing/licenses"; import { getTier1FeaturePriceSet, getTier2FeaturePriceSet, getTier3FeaturePriceSet, } from "@server/lib/billing/features"; import Stripe from "stripe"; import { Tier } from "@server/types/Tiers"; export type SubscriptionType = Tier | "license"; export function getSubType(fullSubscription: Stripe.Response): SubscriptionType | null { // Determine subscription type by checking subscription items if (!Array.isArray(fullSubscription.items?.data) || fullSubscription.items.data.length === 0) { return null; } for (const item of fullSubscription.items.data) { const priceId = item.price.id; // Check if price ID matches any license price const licensePrices = Object.values(getLicensePriceSet()); if (licensePrices.includes(priceId)) { return "license"; } // Check if price ID matches home lab tier const homeLabPrices = Object.values(getTier1FeaturePriceSet()); if (homeLabPrices.includes(priceId)) { return "tier1"; } // Check if price ID matches tier2 tier const tier2Prices = Object.values(getTier2FeaturePriceSet()); if (tier2Prices.includes(priceId)) { return "tier2"; } // Check if price ID matches tier3 tier const tier3Prices = Object.values(getTier3FeaturePriceSet()); if (tier3Prices.includes(priceId)) { return "tier3"; } } return null; } ================================================ FILE: server/private/routers/billing/hooks/handleCustomerCreated.ts ================================================ /* * 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 Stripe from "stripe"; import { customers, db } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; export async function handleCustomerCreated( customer: Stripe.Customer ): Promise { try { const [existingCustomer] = await db .select() .from(customers) .where(eq(customers.customerId, customer.id)) .limit(1); if (existingCustomer) { logger.info(`Customer with ID ${customer.id} already exists.`); return; } if (!customer.metadata.orgId) { logger.error( `Customer with ID ${customer.id} does not have an orgId in metadata.` ); return; } await db.insert(customers).values({ customerId: customer.id, orgId: customer.metadata.orgId, email: customer.email || null, name: customer.name || null, createdAt: customer.created, updatedAt: customer.created }); logger.info(`Customer with ID ${customer.id} created successfully.`); } catch (error) { logger.error( `Error handling customer created event for ID ${customer.id}:`, error ); } return; } ================================================ FILE: server/private/routers/billing/hooks/handleCustomerDeleted.ts ================================================ /* * 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 Stripe from "stripe"; import { customers, db } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; export async function handleCustomerDeleted( customer: Stripe.Customer ): Promise { try { const [existingCustomer] = await db .select() .from(customers) .where(eq(customers.customerId, customer.id)) .limit(1); if (!existingCustomer) { logger.info(`Customer with ID ${customer.id} does not exist.`); return; } await db.delete(customers).where(eq(customers.customerId, customer.id)); } catch (error) { logger.error( `Error handling customer created event for ID ${customer.id}:`, error ); } return; } ================================================ FILE: server/private/routers/billing/hooks/handleCustomerUpdated.ts ================================================ /* * 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 Stripe from "stripe"; import { customers, db } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; export async function handleCustomerUpdated( customer: Stripe.Customer ): Promise { try { const [existingCustomer] = await db .select() .from(customers) .where(eq(customers.customerId, customer.id)) .limit(1); if (!existingCustomer) { logger.info(`Customer with ID ${customer.id} does not exist.`); return; } const newCustomer = { customerId: customer.id, orgId: customer.metadata.orgId, email: customer.email || null, name: customer.name || null, updatedAt: Math.floor(Date.now() / 1000) }; // Update the existing customer record await db .update(customers) .set(newCustomer) .where(eq(customers.customerId, customer.id)); } catch (error) { logger.error( `Error handling customer created event for ID ${customer.id}:`, error ); } return; } ================================================ FILE: server/private/routers/billing/hooks/handleSubscriptionCreated.ts ================================================ /* * 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 Stripe from "stripe"; import { customers, subscriptions, db, subscriptionItems, userOrgs, users } from "@server/db"; import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; import stripe from "#private/lib/stripe"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { getSubType } from "./getSubType"; import privateConfig from "#private/lib/config"; import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses"; import { sendEmail } from "@server/emails"; import EnterpriseEditionKeyGenerated from "@server/emails/templates/EnterpriseEditionKeyGenerated"; import config from "@server/lib/config"; import { getFeatureIdByPriceId } from "@server/lib/billing/features"; import { handleTierChange } from "../featureLifecycle"; export async function handleSubscriptionCreated( subscription: Stripe.Subscription ): Promise { try { // Fetch the subscription from Stripe with expanded price.tiers const fullSubscription = await stripe!.subscriptions.retrieve( subscription.id, { expand: ["items.data.price.tiers"] } ); logger.info(JSON.stringify(fullSubscription, null, 2)); // Check if subscription already exists const [existingSubscription] = await db .select() .from(subscriptions) .where(eq(subscriptions.subscriptionId, subscription.id)) .limit(1); if (existingSubscription) { logger.info( `Subscription with ID ${subscription.id} already exists.` ); return; } const type = getSubType(fullSubscription); const newSubscription = { subscriptionId: subscription.id, customerId: subscription.customer as string, status: subscription.status, canceledAt: subscription.canceled_at ? subscription.canceled_at : null, createdAt: subscription.created, type: type, version: 1 // we are hardcoding the initial version when the subscription is created, and then we will increment it on every update }; await db.insert(subscriptions).values(newSubscription); logger.info( `Subscription with ID ${subscription.id} created successfully.` ); // Insert subscription items if (Array.isArray(fullSubscription.items?.data)) { const itemsToInsertPromises = fullSubscription.items.data.map( async (item) => { // try to get the product name from stripe and add it to the item let name = null; if (item.price.product) { const product = await stripe!.products.retrieve( item.price.product as string ); name = product.name || null; } // Get the feature ID from the price ID const featureId = getFeatureIdByPriceId(item.price.id); return { stripeSubscriptionItemId: item.id, subscriptionId: subscription.id, planId: item.plan.id, priceId: item.price.id, featureId: featureId || null, meterId: item.plan.meter, unitAmount: item.price.unit_amount || 0, currentPeriodStart: item.current_period_start, currentPeriodEnd: item.current_period_end, tiers: item.price.tiers ? JSON.stringify(item.price.tiers) : null, interval: item.plan.interval, name }; } ); // wait for all items to be processed const itemsToInsert = await Promise.all(itemsToInsertPromises); if (itemsToInsert.length > 0) { await db.insert(subscriptionItems).values(itemsToInsert); logger.info( `Inserted ${itemsToInsert.length} subscription items for subscription ${subscription.id}.` ); } } // Lookup customer to get orgId const [customer] = await db .select() .from(customers) .where(eq(customers.customerId, subscription.customer as string)) .limit(1); if (!customer) { logger.error( `Customer with ID ${subscription.customer} not found for subscription ${subscription.id}.` ); return; } if (type === "tier1" || type === "tier2" || type === "tier3") { logger.debug( `Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}` ); // we only need to handle the limit lifecycle for saas subscriptions not for the licenses await handleSubscriptionLifesycle( customer.orgId, subscription.status, type ); // Handle initial tier setup - disable features not available in this tier logger.info( `Setting up initial tier features for org ${customer.orgId} with type ${type}` ); await handleTierChange(customer.orgId, type); const [orgUserRes] = await db .select() .from(userOrgs) .where( and( eq(userOrgs.orgId, customer.orgId), eq(userOrgs.isOwner, true) ) ) .innerJoin(users, eq(userOrgs.userId, users.userId)); if (orgUserRes) { const email = orgUserRes.user.email; if (email) { // TODO: update user in Sendy } } } else if (type === "license") { logger.debug( `License subscription created for org ${customer.orgId}, no lifecycle handling needed.` ); // Retrieve the client_reference_id from the checkout session let licenseId: string | null = null; try { const sessions = await stripe!.checkout.sessions.list({ subscription: subscription.id, limit: 1 }); if (sessions.data.length > 0) { licenseId = sessions.data[0].client_reference_id || null; } if (!licenseId) { logger.error( `No client_reference_id found for subscription ${subscription.id}` ); return; } logger.debug( `Retrieved licenseId ${licenseId} from checkout session for subscription ${subscription.id}` ); // Determine users and sites based on license type const priceSet = getLicensePriceSet(); const subscriptionPriceId = fullSubscription.items.data[0]?.price.id; let numUsers: number; let numSites: number; if (subscriptionPriceId === priceSet[LicenseId.SMALL_LICENSE]) { numUsers = 25; numSites = 25; } else if ( subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE] ) { numUsers = 50; numSites = 50; } else { logger.error( `Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}` ); return; } logger.debug( `License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}` ); const response = await fetch( `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`, { method: "POST", headers: { "api-key": privateConfig.getRawPrivateConfig().server .fossorial_api_key!, "Content-Type": "application/json" }, body: JSON.stringify({ licenseId: parseInt(licenseId), paidFor: true, users: numUsers, sites: numSites }) } ); const data = await response.json(); logger.debug(`Fossorial API response: ${JSON.stringify(data)}`); if (customer.email) { logger.debug( `Sending license key email to ${customer.email} for subscription ${subscription.id}` ); await sendEmail( EnterpriseEditionKeyGenerated({ keyValue: data.data.licenseKey, personalUseOnly: false, users: numUsers, sites: numSites, modifySubscriptionLink: `${config.getRawConfig().app.dashboard_url}/${customer.orgId}/settings/billing` }), { to: customer.email, from: config.getNoReplyEmail(), subject: "Your Enterprise Edition license key is ready" } ); } else { logger.error( `No email found for customer ${customer.customerId} to send license key.` ); } return data; } catch (error) { console.error("Error creating new license:", error); throw error; } } } catch (error) { logger.error( `Error handling subscription created event for ID ${subscription.id}:`, error ); } return; } ================================================ FILE: server/private/routers/billing/hooks/handleSubscriptionDeleted.ts ================================================ /* * 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 Stripe from "stripe"; import { subscriptions, db, subscriptionItems, customers, userOrgs, users } from "@server/db"; import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { getSubType } from "./getSubType"; import stripe from "#private/lib/stripe"; import privateConfig from "#private/lib/config"; import { handleTierChange } from "../featureLifecycle"; export async function handleSubscriptionDeleted( subscription: Stripe.Subscription ): Promise { try { // Fetch the subscription from Stripe with expanded price.tiers const fullSubscription = await stripe!.subscriptions.retrieve( subscription.id, { expand: ["items.data.price.tiers"] } ); const [existingSubscription] = await db .select() .from(subscriptions) .where(eq(subscriptions.subscriptionId, subscription.id)) .limit(1); if (!existingSubscription) { logger.info( `Subscription with ID ${subscription.id} does not exist.` ); return; } await db .delete(subscriptions) .where(eq(subscriptions.subscriptionId, subscription.id)); await db .delete(subscriptionItems) .where(eq(subscriptionItems.subscriptionId, subscription.id)); // Lookup customer to get orgId const [customer] = await db .select() .from(customers) .where(eq(customers.customerId, subscription.customer as string)) .limit(1); if (!customer) { logger.error( `Customer with ID ${subscription.customer} not found for subscription ${subscription.id}.` ); return; } const type = getSubType(fullSubscription); if (type == "tier1" || type == "tier2" || type == "tier3") { logger.debug( `Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}` ); await handleSubscriptionLifesycle( customer.orgId, subscription.status, type ); // Handle feature lifecycle for cancellation - disable all tier-specific features logger.info( `Disabling tier-specific features for org ${customer.orgId} due to subscription deletion` ); await handleTierChange(customer.orgId, null, type); const [orgUserRes] = await db .select() .from(userOrgs) .where( and( eq(userOrgs.orgId, customer.orgId), eq(userOrgs.isOwner, true) ) ) .innerJoin(users, eq(userOrgs.userId, users.userId)); if (orgUserRes) { const email = orgUserRes.user.email; if (email) { // TODO: update user in Sendy } } } else if (type === "license") { logger.debug( `Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}` ); try { // WARNING: // this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId await fetch( `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, { method: "POST", headers: { "api-key": privateConfig.getRawPrivateConfig().server .fossorial_api_key!, "Content-Type": "application/json" }, body: JSON.stringify({ orgId: customer.orgId, }) } ); } catch (error) { logger.error( `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, error ); } } } catch (error) { logger.error( `Error handling subscription updated event for ID ${subscription.id}:`, error ); } return; } ================================================ FILE: server/private/routers/billing/hooks/handleSubscriptionUpdated.ts ================================================ /* * 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 Stripe from "stripe"; import { subscriptions, db, subscriptionItems, usage, sites, customers, orgs } from "@server/db"; import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features"; import stripe from "#private/lib/stripe"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { getSubType, SubscriptionType } from "./getSubType"; import privateConfig from "#private/lib/config"; import { handleTierChange } from "../featureLifecycle"; export async function handleSubscriptionUpdated( subscription: Stripe.Subscription, previousAttributes: Partial | undefined ): Promise { try { // Fetch the subscription from Stripe with expanded price.tiers const fullSubscription = await stripe!.subscriptions.retrieve( subscription.id, { expand: ["items.data.price.tiers"] } ); logger.info(JSON.stringify(fullSubscription, null, 2)); const [existingSubscription] = await db .select() .from(subscriptions) .where(eq(subscriptions.subscriptionId, subscription.id)) .limit(1); if (!existingSubscription) { logger.info( `Subscription with ID ${subscription.id} does not exist.` ); return; } // get the customer const [customer] = await db .select() .from(customers) .where(eq(customers.customerId, subscription.customer as string)) .limit(1); const type = getSubType(fullSubscription); const previousType = existingSubscription.type as SubscriptionType | null; await db .update(subscriptions) .set({ status: subscription.status, canceledAt: subscription.canceled_at ? subscription.canceled_at : null, updatedAt: Math.floor(Date.now() / 1000), billingCycleAnchor: subscription.billing_cycle_anchor, type: type }) .where(eq(subscriptions.subscriptionId, subscription.id)); // Handle tier change if the subscription type changed if (type && type !== previousType) { logger.info( `Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}` ); await handleTierChange(customer.orgId, type, previousType ?? undefined); } // Upsert subscription items if (Array.isArray(fullSubscription.items?.data)) { // First, get existing items to preserve featureId when there's no match const existingItems = await db .select() .from(subscriptionItems) .where(eq(subscriptionItems.subscriptionId, subscription.id)); const itemsToUpsert = fullSubscription.items.data.map((item) => { // Try to get featureId from price let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null; // If no match, try to preserve existing featureId if (!featureId) { const existingItem = existingItems.find( (ei) => ei.stripeSubscriptionItemId === item.id ); featureId = existingItem?.featureId || null; } return { stripeSubscriptionItemId: item.id, subscriptionId: subscription.id, planId: item.plan.id, priceId: item.price.id, featureId: featureId, meterId: item.plan.meter, unitAmount: item.price.unit_amount || 0, currentPeriodStart: item.current_period_start, currentPeriodEnd: item.current_period_end, tiers: item.price.tiers ? JSON.stringify(item.price.tiers) : null, interval: item.plan.interval }; }); if (itemsToUpsert.length > 0) { await db.transaction(async (trx) => { await trx .delete(subscriptionItems) .where( eq( subscriptionItems.subscriptionId, subscription.id ) ); await trx.insert(subscriptionItems).values(itemsToUpsert); }); logger.info( `Updated ${itemsToUpsert.length} subscription items for subscription ${subscription.id}.` ); } // --- Detect cycled items and update usage --- if (previousAttributes) { // Only proceed if latest_invoice changed (per Stripe docs) if ("latest_invoice" in previousAttributes) { // If items array present in previous_attributes, check each item if (Array.isArray(previousAttributes.items?.data)) { for (const item of subscription.items.data) { const prevItem = previousAttributes.items.data.find( (pi: any) => pi.id === item.id ); if ( prevItem && prevItem.current_period_end && item.current_period_start && prevItem.current_period_end === item.current_period_start && item.current_period_start > prevItem.current_period_start ) { logger.info( `Subscription item ${item.id} has cycled. Resetting usage.` ); } else { continue; } // This item has cycled const meterId = item.plan.meter; if (!meterId) { logger.debug( `No meterId found for subscription item ${item.id}. Skipping usage reset.` ); continue; } const featureId = getFeatureIdByMetricId(meterId); if (!featureId) { logger.debug( `No featureId found for meterId ${meterId}. Skipping usage reset.` ); continue; } const orgId = customer.orgId; if (!orgId) { logger.debug( `No orgId found in subscription metadata for subscription ${subscription.id}. Skipping usage reset.` ); continue; } await db.transaction(async (trx) => { const [usageRow] = await trx .select() .from(usage) .where( eq( usage.usageId, `${orgId}-${featureId}` ) ) .limit(1); if (usageRow) { // get the next rollover date const [org] = await trx .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); const lastRollover = usageRow.rolledOverAt ? new Date(usageRow.rolledOverAt * 1000) : new Date(); const anchorDate = org.createdAt ? new Date(org.createdAt) : new Date(); const nextRollover = calculateNextRollOverDate( lastRollover, anchorDate ); await trx .update(usage) .set({ previousValue: usageRow.latestValue, latestValue: usageRow.instantaneousValue || 0, updatedAt: Math.floor( Date.now() / 1000 ), rolledOverAt: Math.floor( Date.now() / 1000 ), nextRolloverAt: Math.floor( nextRollover.getTime() / 1000 ) }) .where( eq(usage.usageId, usageRow.usageId) ); logger.info( `Usage reset for org ${orgId}, meter ${featureId} on subscription item cycle.` ); } // Also reset the sites to 0 await trx .update(sites) .set({ megabytesIn: 0, megabytesOut: 0 }) .where(eq(sites.orgId, orgId)); }); } } } } // --- end usage update --- if (type === "tier1" || type === "tier2" || type === "tier3") { logger.debug( `Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}` ); // we only need to handle the limit lifecycle for saas subscriptions not for the licenses await handleSubscriptionLifesycle( customer.orgId, subscription.status, type ); // Handle feature lifecycle when subscription is canceled or becomes unpaid if ( subscription.status === "canceled" || subscription.status === "unpaid" || subscription.status === "incomplete_expired" ) { logger.info( `Subscription ${subscription.id} for org ${customer.orgId} is ${subscription.status}, disabling paid features` ); await handleTierChange(customer.orgId, null, previousType ?? undefined); } } else if (type === "license") { if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") { try { // WARNING: // this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId await fetch( `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, { method: "POST", headers: { "api-key": privateConfig.getRawPrivateConfig() .server.fossorial_api_key!, "Content-Type": "application/json" }, body: JSON.stringify({ orgId: customer.orgId }) } ); } catch (error) { logger.error( `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, error ); } } } } } catch (error) { logger.error( `Error handling subscription updated event for ID ${subscription.id}:`, error ); } return; } /** * Calculate the next billing date based on monthly billing cycle * Handles end-of-month scenarios as described in the requirements * Made public for testing */ function calculateNextRollOverDate(lastRollover: Date, anchorDate: Date): Date { const rolloverDate = new Date(lastRollover); const anchor = new Date(anchorDate); // Get components from rollover date const rolloverYear = rolloverDate.getUTCFullYear(); const rolloverMonth = rolloverDate.getUTCMonth(); // Calculate target month and year (next month) let targetMonth = rolloverMonth + 1; let targetYear = rolloverYear; if (targetMonth > 11) { targetMonth = 0; targetYear++; } // Get anchor day for billing const anchorDay = anchor.getUTCDate(); // Get the last day of the target month const lastDayOfMonth = new Date( Date.UTC(targetYear, targetMonth + 1, 0) ).getUTCDate(); // Use the anchor day or the last day of the month, whichever is smaller const targetDay = Math.min(anchorDay, lastDayOfMonth); // Create the next billing date using UTC const nextBilling = new Date( Date.UTC( targetYear, targetMonth, targetDay, anchor.getUTCHours(), anchor.getUTCMinutes(), anchor.getUTCSeconds(), anchor.getUTCMilliseconds() ) ); return nextBilling; } ================================================ FILE: server/private/routers/billing/index.ts ================================================ /* * 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 "./createCheckoutSession"; export * from "./createPortalSession"; export * from "./getOrgSubscriptions"; export * from "./getOrgUsage"; export * from "./internalGetOrgTier"; export * from "./changeTier"; ================================================ FILE: server/private/routers/billing/internalGetOrgTier.ts ================================================ /* * 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 response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { getOrgTierData } from "#private/lib/billing"; import { GetOrgTierResponse } from "@server/routers/billing/types"; const getOrgSchema = z.strictObject({ orgId: z.string() }); export async function getOrgTier( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getOrgSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const { orgId } = parsedParams.data; let tierData = null; let activeData = false; try { const { tier, active } = await getOrgTierData(orgId); tierData = tier; activeData = active; } catch (err) { if ((err as Error).message === "Not found") { return next( createHttpError( HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found` ) ); } throw err; } return response(res, { data: { tier: tierData, active: activeData }, success: true, error: false, message: "Organization and subscription retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/billing/subscriptionLifecycle.ts ================================================ /* * 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 { freeLimitSet, tier1LimitSet, tier2LimitSet, tier3LimitSet, limitsService, LimitSet } from "@server/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; import { SubscriptionType } from "./hooks/getSubType"; function getLimitSetForSubscriptionType( subType: SubscriptionType | null ): LimitSet { switch (subType) { case "tier1": return tier1LimitSet; case "tier2": return tier2LimitSet; case "tier3": return tier3LimitSet; case "license": // License subscriptions use tier2 limits by default // This can be adjusted based on your business logic return tier2LimitSet; default: return freeLimitSet; } } export async function handleSubscriptionLifesycle( orgId: string, status: string, subType: SubscriptionType | null ) { switch (status) { case "active": const activeLimitSet = getLimitSetForSubscriptionType(subType); await limitsService.applyLimitSetToOrg(orgId, activeLimitSet); await usageService.checkLimitSet(orgId); break; case "canceled": // Subscription canceled - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); await usageService.checkLimitSet(orgId); break; case "past_due": // Payment past due - keep current limits but notify customer // Limits will revert to free tier if it becomes unpaid break; case "unpaid": // Subscription unpaid - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); await usageService.checkLimitSet(orgId); break; case "incomplete": // Payment incomplete - give them time to complete payment break; case "incomplete_expired": // Payment never completed - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); await usageService.checkLimitSet(orgId); break; default: break; } } ================================================ FILE: server/private/routers/billing/webhooks.ts ================================================ /* * 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 stripe from "#private/lib/stripe"; import privateConfig from "#private/lib/config"; import logger from "@server/logger"; import createHttpError from "http-errors"; import { response } from "@server/lib/response"; import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; import Stripe from "stripe"; import { handleCustomerCreated } from "./hooks/handleCustomerCreated"; import { handleSubscriptionCreated } from "./hooks/handleSubscriptionCreated"; import { handleSubscriptionUpdated } from "./hooks/handleSubscriptionUpdated"; import { handleCustomerUpdated } from "./hooks/handleCustomerUpdated"; import { handleSubscriptionDeleted } from "./hooks/handleSubscriptionDeleted"; import { handleCustomerDeleted } from "./hooks/handleCustomerDeleted"; export async function billingWebhookHandler( req: Request, res: Response, next: NextFunction ): Promise { let event: Stripe.Event = req.body; const endpointSecret = privateConfig.getRawPrivateConfig().stripe?.webhook_secret; if (!endpointSecret) { logger.warn( "Stripe webhook secret is not configured. Webhook events will not be priocessed." ); return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "")); } // Only verify the event if you have an endpoint secret defined. // Otherwise use the basic event deserialized with JSON.parse if (endpointSecret) { // Get the signature sent by Stripe const signature = req.headers["stripe-signature"]; if (!signature) { logger.info("No stripe signature found in headers."); return next( createHttpError( HttpCode.BAD_REQUEST, "No stripe signature found in headers" ) ); } try { event = stripe!.webhooks.constructEvent( req.body, signature, endpointSecret ); } catch (err) { logger.error(`Webhook signature verification failed.`, err); return next( createHttpError( HttpCode.UNAUTHORIZED, "Webhook signature verification failed" ) ); } } let subscription; let previousAttributes; // Handle the event switch (event.type) { case "customer.created": const customer = event.data.object; logger.info("Customer created: ", customer); handleCustomerCreated(customer); break; case "customer.updated": const customerUpdated = event.data.object; logger.info("Customer updated: ", customerUpdated); handleCustomerUpdated(customerUpdated); break; case "customer.deleted": const customerDeleted = event.data.object; logger.info("Customer deleted: ", customerDeleted); handleCustomerDeleted(customerDeleted); break; case "customer.subscription.paused": subscription = event.data.object; previousAttributes = event.data.previous_attributes; handleSubscriptionUpdated(subscription, previousAttributes); break; case "customer.subscription.resumed": subscription = event.data.object; previousAttributes = event.data.previous_attributes; handleSubscriptionUpdated(subscription, previousAttributes); break; case "customer.subscription.deleted": subscription = event.data.object; handleSubscriptionDeleted(subscription); break; case "customer.subscription.created": subscription = event.data.object; handleSubscriptionCreated(subscription); break; case "customer.subscription.updated": subscription = event.data.object; previousAttributes = event.data.previous_attributes; handleSubscriptionUpdated(subscription, previousAttributes); break; case "customer.subscription.trial_will_end": subscription = event.data.object; // Then define and call a method to handle the subscription trial ending. // handleSubscriptionTrialEnding(subscription); break; case "entitlements.active_entitlement_summary.updated": subscription = event.data.object; logger.info( `Active entitlement summary updated for ${subscription}.` ); // Then define and call a method to handle active entitlement summary updated // handleEntitlementUpdated(subscription); break; default: // Unexpected event type logger.info(`Unhandled event type ${event.type}.`); } // Return a 200 response to acknowledge receipt of the event return response(res, { data: null, success: true, error: false, message: "Webhook event processed successfully", status: HttpCode.CREATED }); } ================================================ FILE: server/private/routers/certificates/createCertificate.ts ================================================ /* * 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 { Certificate, certificates, db, domains } from "@server/db"; import logger from "@server/logger"; import { Transaction } from "@server/db"; import { eq, or, and, like } from "drizzle-orm"; import privateConfig from "#private/lib/config"; /** * Checks if a certificate exists for the given domain. * If not, creates a new certificate in 'pending' state. * Wildcard certs cover subdomains. */ export async function createCertificate( domainId: string, domain: string, trx: Transaction | typeof db ) { if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { return; } const [domainRecord] = await trx .select() .from(domains) .where(eq(domains.domainId, domainId)) .limit(1); if (!domainRecord) { throw new Error(`Domain with ID ${domainId} not found`); } let existing: Certificate[] = []; if (domainRecord.type == "ns") { const domainLevelDown = domain.split(".").slice(1).join("."); existing = await trx .select() .from(certificates) .where( and( eq(certificates.domainId, domainId), eq(certificates.wildcard, true), // only NS domains can have wildcard certs or( eq(certificates.domain, domain), eq(certificates.domain, domainLevelDown) ) ) ); } else { // For non-NS domains, we only match exact domain names existing = await trx .select() .from(certificates) .where( and( eq(certificates.domainId, domainId), eq(certificates.domain, domain) // exact match for non-NS domains ) ); } if (existing.length > 0) { logger.info(`Certificate already exists for domain ${domain}`); return; } // No cert found, create a new one in pending state await trx.insert(certificates).values({ domain, domainId, wildcard: domainRecord.type == "ns", // we can only create wildcard certs for NS domains status: "pending", updatedAt: Math.floor(Date.now() / 1000), createdAt: Math.floor(Date.now() / 1000) }); } ================================================ FILE: server/private/routers/certificates/getCertificate.ts ================================================ /* * 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 { certificates, db, domains } from "@server/db"; import { eq, and, or, like } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { registry } from "@server/openApi"; import { GetCertificateResponse } from "@server/routers/certificates/types"; const getCertificateSchema = z.strictObject({ domainId: z.string(), domain: z.string().min(1).max(255), orgId: z.string() }); async function query(domainId: string, domain: string) { const [domainRecord] = await db .select() .from(domains) .where(eq(domains.domainId, domainId)) .limit(1); if (!domainRecord) { throw new Error(`Domain with ID ${domainId} not found`); } let existing: any[] = []; if (domainRecord.type == "ns") { const domainLevelDown = domain.split(".").slice(1).join("."); existing = await db .select({ certId: certificates.certId, domain: certificates.domain, wildcard: certificates.wildcard, status: certificates.status, expiresAt: certificates.expiresAt, lastRenewalAttempt: certificates.lastRenewalAttempt, createdAt: certificates.createdAt, updatedAt: certificates.updatedAt, errorMessage: certificates.errorMessage, renewalCount: certificates.renewalCount }) .from(certificates) .where( and( eq(certificates.domainId, domainId), eq(certificates.wildcard, true), // only NS domains can have wildcard certs or( eq(certificates.domain, domain), eq(certificates.domain, domainLevelDown) ) ) ); } else { // For non-NS domains, we only match exact domain names existing = await db .select({ certId: certificates.certId, domain: certificates.domain, wildcard: certificates.wildcard, status: certificates.status, expiresAt: certificates.expiresAt, lastRenewalAttempt: certificates.lastRenewalAttempt, createdAt: certificates.createdAt, updatedAt: certificates.updatedAt, errorMessage: certificates.errorMessage, renewalCount: certificates.renewalCount }) .from(certificates) .where( and( eq(certificates.domainId, domainId), eq(certificates.domain, domain) // exact match for non-NS domains ) ); } return existing.length > 0 ? existing[0] : null; } registry.registerPath({ method: "get", path: "/org/{orgId}/certificate/{domainId}/{domain}", description: "Get a certificate by domain.", tags: ["Certificate"], request: { params: z.object({ domainId: z.string(), domain: z.string().min(1).max(255), orgId: z.string() }) }, responses: {} }); export async function getCertificate( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getCertificateSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { domainId, domain } = parsedParams.data; const cert = await query(domainId, domain); if (!cert) { logger.warn(`Certificate not found for domain: ${domainId}`); return next( createHttpError(HttpCode.NOT_FOUND, "Certificate not found") ); } return response(res, { data: cert, success: true, error: false, message: "Certificate retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/certificates/index.ts ================================================ /* * 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 "./getCertificate"; export * from "./restartCertificate"; ================================================ FILE: server/private/routers/certificates/restartCertificate.ts ================================================ /* * 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 { certificates, db } from "@server/db"; import { sites } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const restartCertificateParamsSchema = z.strictObject({ certId: z.string().transform(stoi).pipe(z.int().positive()), orgId: z.string() }); registry.registerPath({ method: "post", path: "/certificate/{certId}", description: "Restart a certificate by ID.", tags: ["Certificate"], request: { params: z.object({ certId: z.string().transform(stoi).pipe(z.int().positive()), orgId: z.string() }) }, responses: {} }); export async function restartCertificate( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = restartCertificateParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { certId } = parsedParams.data; // get the certificate by ID const [cert] = await db .select() .from(certificates) .where(eq(certificates.certId, certId)) .limit(1); if (!cert) { return next( createHttpError(HttpCode.NOT_FOUND, "Certificate not found") ); } if (cert.status != "failed" && cert.status != "expired") { return next( createHttpError( HttpCode.BAD_REQUEST, "Certificate is already valid, no need to restart" ) ); } // update the certificate status to 'pending' await db .update(certificates) .set({ status: "pending", errorMessage: null, lastRenewalAttempt: Math.floor(Date.now() / 1000) }) .where(eq(certificates.certId, certId)); return response(res, { data: null, success: true, error: false, message: "Certificate restarted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/domain/checkDomainNamespaceAvailability.ts ================================================ /* * 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 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 { db, domainNamespaces, resources } from "@server/db"; import { inArray } from "drizzle-orm"; import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types"; const paramsSchema = z.strictObject({}); const querySchema = z.strictObject({ subdomain: z.string() }); registry.registerPath({ method: "get", path: "/domain/check-namespace-availability", description: "Check if a domain namespace is available based on subdomain", tags: [OpenAPITags.Domain], request: { params: paramsSchema, query: querySchema }, responses: {} }); export async function checkDomainNamespaceAvailability( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { subdomain } = parsedQuery.data; const namespaces = await db.select().from(domainNamespaces); let possibleDomains = namespaces.map((ns) => { const desired = `${subdomain}.${ns.domainNamespaceId}`; return { fullDomain: desired, domainId: ns.domainId, domainNamespaceId: ns.domainNamespaceId }; }); if (!possibleDomains.length) { return response(res, { data: { available: false, options: [] }, success: true, error: false, message: "No domain namespaces available", status: HttpCode.OK }); } const existingResources = await db .select() .from(resources) .where( inArray( resources.fullDomain, possibleDomains.map((d) => d.fullDomain) ) ); possibleDomains = possibleDomains.filter( (domain) => !existingResources.some( (resource) => resource.fullDomain === domain.fullDomain ) ); return response(res, { data: { available: possibleDomains.length > 0, options: possibleDomains }, success: true, error: false, message: "Domain namespaces checked successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/domain/index.ts ================================================ /* * 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 "./checkDomainNamespaceAvailability"; export * from "./listDomainNamespaces"; ================================================ FILE: server/private/routers/domain/listDomainNamespaces.ts ================================================ /* * 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, domainNamespaces } from "@server/db"; import { domains } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.strictObject({}); const querySchema = z.strictObject({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().nonnegative()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); async function query(limit: number, offset: number) { const res = await db .select({ domainNamespaceId: domainNamespaces.domainNamespaceId, domainId: domainNamespaces.domainId }) .from(domainNamespaces) .innerJoin( domains, eq(domains.domainId, domainNamespaces.domainNamespaceId) ) .limit(limit) .offset(offset); return res; } export type ListDomainNamespacesResponse = { domainNamespaces: NonNullable>>; pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ method: "get", path: "/domains/namepaces", description: "List all domain namespaces in the system", tags: [OpenAPITags.Domain], request: { query: querySchema }, responses: {} }); export async function listDomainNamespaces( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const domainNamespacesList = await query(limit, offset); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(domainNamespaces); return response(res, { data: { domainNamespaces: domainNamespacesList, pagination: { total: count, limit, offset } }, success: true, error: false, message: "Namespaces retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/external.ts ================================================ /* * 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 certificates from "#private/routers/certificates"; import { createStore } from "#private/lib/rateLimitStore"; import * as billing from "#private/routers/billing"; import * as remoteExitNode from "#private/routers/remoteExitNode"; import * as loginPage from "#private/routers/loginPage"; import * as orgIdp from "#private/routers/orgIdp"; import * as domain from "#private/routers/domain"; import * as auth from "#private/routers/auth"; import * as license from "#private/routers/license"; import * as generateLicense from "./generatedLicense"; 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, verifyUserHasAction, verifyUserIsServerAdmin, verifySiteAccess, verifyClientAccess, verifyLimits } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { logActionAudit, verifyCertificateAccess, verifyIdpAccess, verifyLoginPageAccess, verifyRemoteExitNodeAccess, verifyValidSubscription } from "#private/middlewares"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { verifyValidLicense } from "../middlewares/verifyValidLicense"; import { build } from "@server/build"; import { unauthenticated as ua, authenticated as a, authRouter as aa } from "@server/routers/external"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; export const authenticated = a; export const unauthenticated = ua; export const authRouter = aa; unauthenticated.post( "/remote-exit-node/quick-start", verifyValidLicense, rateLimit({ windowMs: 60 * 60 * 1000, max: 5, keyGenerator: (req) => `${req.path}:${ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only create 5 remote exit nodes every hour. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), remoteExitNode.quickStartRemoteExitNode ); authenticated.put( "/org/:orgId/idp/oidc", verifyValidLicense, verifyValidSubscription(tierMatrix.orgOidc), verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createIdp), logActionAudit(ActionsEnum.createIdp), orgIdp.createOrgOidcIdp ); authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, verifyValidSubscription(tierMatrix.orgOidc), verifyOrgAccess, verifyIdpAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateIdp), logActionAudit(ActionsEnum.updateIdp), orgIdp.updateOrgOidcIdp ); authenticated.delete( "/org/:orgId/idp/:idpId", verifyValidLicense, verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), logActionAudit(ActionsEnum.deleteIdp), orgIdp.deleteOrgIdp ); authenticated.get( "/org/:orgId/idp/:idpId", verifyValidLicense, verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.getIdp), orgIdp.getOrgIdp ); authenticated.get( "/org/:orgId/idp", verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listIdps), orgIdp.listOrgIdps ); authenticated.get("/org/:orgId/idp", orgIdp.listOrgIdps); // anyone can see this; it's just a list of idp names and ids authenticated.get( "/org/:orgId/certificate/:domainId/:domain", verifyValidLicense, verifyOrgAccess, verifyCertificateAccess, verifyUserHasAction(ActionsEnum.getCertificate), certificates.getCertificate ); authenticated.post( "/org/:orgId/certificate/:certId/restart", verifyValidLicense, verifyOrgAccess, verifyCertificateAccess, verifyLimits, verifyUserHasAction(ActionsEnum.restartCertificate), logActionAudit(ActionsEnum.restartCertificate), certificates.restartCertificate ); if (build === "saas") { authenticated.post( "/org/:orgId/billing/create-checkout-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), logActionAudit(ActionsEnum.billing), billing.createCheckoutSession ); authenticated.post( "/org/:orgId/billing/change-tier", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), logActionAudit(ActionsEnum.billing), billing.changeTier ); authenticated.post( "/org/:orgId/billing/create-portal-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), logActionAudit(ActionsEnum.billing), billing.createPortalSession ); authenticated.get( "/org/:orgId/billing/subscriptions", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), billing.getOrgSubscriptions ); authenticated.get( "/org/:orgId/billing/usage", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), billing.getOrgUsage ); authenticated.get( "/org/:orgId/license", verifyOrgAccess, generateLicense.listSaasLicenseKeys ); authenticated.put( "/org/:orgId/license", verifyOrgAccess, generateLicense.generateNewLicense ); authenticated.put( "/org/:orgId/license/enterprise", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), logActionAudit(ActionsEnum.billing), generateLicense.generateNewEnterpriseLicense ); authenticated.post( "/send-support-request", rateLimit({ windowMs: 15 * 60 * 1000, max: 3, keyGenerator: (req) => `sendSupportRequest:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only send 3 support requests every 15 minutes. Please try again later.`; return next( createHttpError(HttpCode.TOO_MANY_REQUESTS, message) ); }, store: createStore() }), misc.sendSupportEmail ); } authenticated.get( "/domain/namespaces", verifyValidLicense, domain.listDomainNamespaces ); authenticated.get( "/domain/check-namespace-availability", verifyValidLicense, domain.checkDomainNamespaceAvailability ); authenticated.put( "/org/:orgId/remote-exit-node", verifyValidLicense, verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createRemoteExitNode), logActionAudit(ActionsEnum.createRemoteExitNode), remoteExitNode.createRemoteExitNode ); authenticated.get( "/org/:orgId/remote-exit-nodes", verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listRemoteExitNode), remoteExitNode.listRemoteExitNodes ); authenticated.get( "/org/:orgId/remote-exit-node/:remoteExitNodeId", verifyValidLicense, verifyOrgAccess, verifyRemoteExitNodeAccess, verifyUserHasAction(ActionsEnum.getRemoteExitNode), remoteExitNode.getRemoteExitNode ); authenticated.get( "/org/:orgId/pick-remote-exit-node-defaults", verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRemoteExitNode), remoteExitNode.pickRemoteExitNodeDefaults ); authenticated.delete( "/org/:orgId/remote-exit-node/:remoteExitNodeId", verifyValidLicense, verifyOrgAccess, verifyRemoteExitNodeAccess, verifyUserHasAction(ActionsEnum.deleteRemoteExitNode), logActionAudit(ActionsEnum.deleteRemoteExitNode), remoteExitNode.deleteRemoteExitNode ); authenticated.put( "/org/:orgId/login-page", verifyValidLicense, verifyValidSubscription(tierMatrix.loginPageDomain), verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createLoginPage), logActionAudit(ActionsEnum.createLoginPage), loginPage.createLoginPage ); authenticated.post( "/org/:orgId/login-page/:loginPageId", verifyValidLicense, verifyValidSubscription(tierMatrix.loginPageDomain), verifyOrgAccess, verifyLoginPageAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateLoginPage), logActionAudit(ActionsEnum.updateLoginPage), loginPage.updateLoginPage ); authenticated.delete( "/org/:orgId/login-page/:loginPageId", verifyValidLicense, verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), logActionAudit(ActionsEnum.deleteLoginPage), loginPage.deleteLoginPage ); authenticated.get( "/org/:orgId/login-page", verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.getLoginPage), loginPage.getLoginPage ); authenticated.get( "/org/:orgId/approvals", verifyValidLicense, verifyValidSubscription(tierMatrix.deviceApprovals), verifyOrgAccess, verifyUserHasAction(ActionsEnum.listApprovals), logActionAudit(ActionsEnum.listApprovals), approval.listApprovals ); authenticated.get( "/org/:orgId/approvals/count", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listApprovals), approval.countApprovals ); authenticated.put( "/org/:orgId/approvals/:approvalId", verifyValidLicense, verifyValidSubscription(tierMatrix.deviceApprovals), verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateApprovals), logActionAudit(ActionsEnum.updateApprovals), approval.processPendingApproval ); authenticated.get( "/org/:orgId/login-page-branding", verifyValidLicense, verifyValidSubscription(tierMatrix.loginPageBranding), verifyOrgAccess, verifyUserHasAction(ActionsEnum.getLoginPage), logActionAudit(ActionsEnum.getLoginPage), loginPage.getLoginPageBranding ); authenticated.put( "/org/:orgId/login-page-branding", verifyValidLicense, verifyValidSubscription(tierMatrix.loginPageBranding), verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateLoginPage), logActionAudit(ActionsEnum.updateLoginPage), loginPage.upsertLoginPageBranding ); authenticated.delete( "/org/:orgId/login-page-branding", verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), logActionAudit(ActionsEnum.deleteLoginPage), loginPage.deleteLoginPageBranding ); authRouter.post( "/remoteExitNode/get-token", verifyValidLicense, rateLimit({ windowMs: 15 * 60 * 1000, max: 900, keyGenerator: (req) => `remoteExitNodeGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only request an remoteExitNodeToken token ${900} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), remoteExitNode.getRemoteExitNodeToken ); authRouter.post( "/transfer-session-token", verifyValidLicense, rateLimit({ windowMs: 1 * 60 * 1000, max: 60, keyGenerator: (req) => `transferSessionToken:${ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only transfer a session token ${5} times every ${1} minute. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.transferSession ); authenticated.post( "/license/activate", verifyUserIsServerAdmin, license.activateLicense ); authenticated.get( "/license/keys", verifyUserIsServerAdmin, license.listLicenseKeys ); authenticated.delete( "/license/:licenseKey", verifyUserIsServerAdmin, license.deleteLicenseKey ); authenticated.post( "/license/recheck", verifyUserIsServerAdmin, license.recheckStatus ); authenticated.get( "/org/:orgId/logs/action", verifyValidLicense, verifyValidSubscription(tierMatrix.actionLogs), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logs.queryActionAuditLogs ); authenticated.get( "/org/:orgId/logs/action/export", verifyValidLicense, verifyValidSubscription(tierMatrix.logExport), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), logs.exportActionAuditLogs ); authenticated.get( "/org/:orgId/logs/access", verifyValidLicense, verifyValidSubscription(tierMatrix.accessLogs), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logs.queryAccessAuditLogs ); authenticated.get( "/org/:orgId/logs/access/export", verifyValidLicense, verifyValidSubscription(tierMatrix.logExport), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), logs.exportAccessAuditLogs ); authenticated.post( "/re-key/:clientId/regenerate-client-secret", verifyClientAccess, // this is first to set the org id verifyValidLicense, verifyValidSubscription(tierMatrix.rotateCredentials), verifyLimits, verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateClientSecret ); authenticated.post( "/re-key/:siteId/regenerate-site-secret", verifySiteAccess, // this is first to set the org id verifyValidLicense, verifyValidSubscription(tierMatrix.rotateCredentials), verifyLimits, verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateSiteSecret ); authenticated.put( "/re-key/:orgId/regenerate-remote-exit-node-secret", verifyValidLicense, verifyValidSubscription(tierMatrix.rotateCredentials), verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateExitNodeSecret ); authenticated.post( "/org/:orgId/ssh/sign-key", verifyValidLicense, verifyValidSubscription(tierMatrix.sshPam), verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.signSshKey), // logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata ssh.signSshKey ); ================================================ FILE: server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts ================================================ /* * 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import privateConfig from "#private/lib/config"; import { createNewLicense } from "./generateNewLicense"; import config from "@server/lib/config"; import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses"; import stripe from "#private/lib/stripe"; import { customers, db } from "@server/db"; import { fromError } from "zod-validation-error"; import z from "zod"; import { eq } from "drizzle-orm"; import { log } from "winston"; const generateNewEnterpriseLicenseParamsSchema = z.strictObject({ orgId: z.string() }); export async function generateNewEnterpriseLicense( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; if (!orgId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Organization ID is required" ) ); } logger.debug(`Generating new license for orgId: ${orgId}`); const licenseData = req.body; if ( licenseData.tier != "big_license" && licenseData.tier != "small_license" ) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid tier specified. Must be either 'big_license' or 'small_license'." ) ); } const apiResponse = await createNewLicense(orgId, licenseData); // Check if the API call was successful if (!apiResponse.success || apiResponse.error) { return next( createHttpError( apiResponse.status || HttpCode.BAD_REQUEST, apiResponse.message || "Failed to create license from Fossorial API" ) ); } const keyId = apiResponse?.data?.licenseKey?.id; if (!keyId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Fossorial API did not return a valid license key ID" ) ); } // check if we already have a customer for this org const [customer] = await db .select() .from(customers) .where(eq(customers.orgId, orgId)) .limit(1); // If we don't have a customer, create one if (!customer) { // error return next( createHttpError( HttpCode.BAD_REQUEST, "No customer found for this organization" ) ); } const tier = licenseData.tier === "big_license" ? LicenseId.BIG_LICENSE : LicenseId.SMALL_LICENSE; const tierPrice = getLicensePriceSet()[tier]; const session = await stripe!.checkout.sessions.create({ client_reference_id: keyId.toString(), billing_address_collection: "required", line_items: [ { price: tierPrice, // Use the standard tier quantity: 1 } ], // Start with the standard feature set that matches the free limits customer: customer.customerId, mode: "subscription", allow_promotion_codes: true, success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true` }); return sendResponse(res, { data: session.url, success: true, error: false, message: "License and checkout session created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred while generating new license." ) ); } } ================================================ FILE: server/private/routers/generatedLicense/generateNewLicense.ts ================================================ /* * 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import privateConfig from "#private/lib/config"; import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; export interface CreateNewLicenseResponse { data: Data success: boolean error: boolean message: string status: number } export interface Data { licenseKey: LicenseKey } export interface LicenseKey { id: number instanceName: any instanceId: string licenseKey: string tier: string type: string quantity: number quantity_2: number isValid: boolean updatedAt: string createdAt: string expiresAt: string paidFor: boolean orgId: string metadata: string } export async function createNewLicense(orgId: string, licenseData: any): Promise { try { const response = await fetch( `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both { method: "PUT", headers: { "api-key": privateConfig.getRawPrivateConfig().server .fossorial_api_key!, "Content-Type": "application/json" }, body: JSON.stringify(licenseData) } ); const data: CreateNewLicenseResponse = await response.json(); return data; } catch (error) { console.error("Error creating new license:", error); throw error; } } export async function generateNewLicense( req: Request, res: Response, next: NextFunction ): Promise { try { const { orgId } = req.params; if (!orgId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Organization ID is required" ) ); } logger.debug(`Generating new license for orgId: ${orgId}`); const licenseData = req.body; const apiResponse = await createNewLicense(orgId, licenseData); return sendResponse(res, { data: apiResponse.data, success: apiResponse.success, error: apiResponse.error, message: apiResponse.message, status: apiResponse.status }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred while generating new license" ) ); } } ================================================ FILE: server/private/routers/generatedLicense/index.ts ================================================ /* * 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 "./listGeneratedLicenses"; export * from "./generateNewLicense"; export * from "./generateNewEnterpriseLicense"; ================================================ FILE: server/private/routers/generatedLicense/listGeneratedLicenses.ts ================================================ /* * 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import privateConfig from "#private/lib/config"; import { GeneratedLicenseKey, ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types"; async function fetchLicenseKeys(orgId: string): Promise { try { const response = await fetch( `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/list`, { method: "GET", headers: { "api-key": privateConfig.getRawPrivateConfig().server .fossorial_api_key!, "Content-Type": "application/json" } } ); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); return data; } catch (error) { console.error("Error fetching license keys:", error); throw error; } } export async function listSaasLicenseKeys( req: Request, res: Response, next: NextFunction ): Promise { try { const { orgId } = req.params; if (!orgId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Organization ID is required" ) ); } const apiResponse = await fetchLicenseKeys(orgId); const keys: GeneratedLicenseKey[] = apiResponse.data.licenseKeys || []; return sendResponse(res, { data: keys, success: true, error: false, message: "Successfully retrieved license keys", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred while fetching license keys" ) ); } } ================================================ FILE: server/private/routers/gerbil/createExitNode.ts ================================================ /* * 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 { db, ExitNode, exitNodes } from "@server/db"; import { getUniqueExitNodeEndpointName } from "@server/db/names"; import config from "@server/lib/config"; import { getNextAvailableSubnet } from "@server/lib/exitNodes"; import logger from "@server/logger"; import { eq } from "drizzle-orm"; export async function createExitNode( publicKey: string, reachableAt: string | undefined ) { // Fetch exit node const [exitNodeQuery] = await db .select() .from(exitNodes) .where(eq(exitNodes.publicKey, publicKey)); let exitNode: ExitNode; if (!exitNodeQuery) { const address = await getNextAvailableSubnet(); // TODO: eventually we will want to get the next available port so that we can multiple exit nodes // const listenPort = await getNextAvailablePort(); const listenPort = config.getRawConfig().gerbil.start_port; let subEndpoint = ""; if (config.getRawConfig().gerbil.use_subdomain) { subEndpoint = await getUniqueExitNodeEndpointName(); } const exitNodeName = config.getRawConfig().gerbil.exit_node_name || `Exit Node ${publicKey.slice(0, 8)}`; // create a new exit node [exitNode] = await db .insert(exitNodes) .values({ publicKey, endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`, address, listenPort, online: true, reachableAt, name: exitNodeName }) .returning() .execute(); logger.info( `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` ); } else { // update the reachable at [exitNode] = await db .update(exitNodes) .set({ reachableAt, online: true }) .where(eq(exitNodes.exitNodeId, exitNodeQuery.exitNodeId)) .returning(); logger.info(`Updated exit node reachableAt to ${reachableAt}`); } return exitNode; } ================================================ FILE: server/private/routers/hybrid.ts ================================================ /* * 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 { verifySessionRemoteExitNodeMiddleware } from "#private/middlewares/verifyRemoteExitNode"; import { Router } from "express"; import { db, exitNodes, Resource, ResourcePassword, ResourcePincode, Session, User, certificates, exitNodeOrgs, RemoteExitNode, olms, newts, clients, sites, domains, orgDomains, targets, loginPage, loginPageOrg, LoginPage, resourceHeaderAuth, ResourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, ResourceHeaderAuthExtendedCompatibility, orgs, requestAuditLog, Org } from "@server/db"; import { resources, resourcePincode, resourcePassword, sessions, users, userOrgs, roleResources, userResources, resourceRules } from "@server/db"; import { eq, and, inArray, isNotNull, ne } from "drizzle-orm"; import { response } from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getTraefikConfig } from "#private/lib/traefik"; import { generateGerbilConfig, generateRelayMappings, updateAndGenerateEndpointDestinations, updateSiteBandwidth } from "@server/routers/gerbil"; import * as gerbil from "@server/routers/gerbil"; import logger from "@server/logger"; import { decryptData } from "@server/lib/encryption"; import config from "@server/lib/config"; import privateConfig from "#private/lib/config"; import * as fs from "fs"; import { exchangeSession } from "@server/routers/badger"; import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { maxmindLookup } from "@server/db/maxmind"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import semver from "semver"; import { maxmindAsnLookup } from "@server/db/maxmindAsn"; import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy"; // Zod schemas for request validation const getResourceByDomainParamsSchema = z.strictObject({ domain: z.string().min(1, "Domain is required") }); const getUserSessionParamsSchema = z.strictObject({ userSessionId: z.string().min(1, "User session ID is required") }); const getUserOrgRoleParamsSchema = z.strictObject({ userId: z.string().min(1, "User ID is required"), orgId: z.string().min(1, "Organization ID is required") }); const getUserOrgSessionVerifySchema = z.strictObject({ userId: z.string().min(1, "User ID is required"), orgId: z.string().min(1, "Organization ID is required"), sessionId: z.string().min(1, "Session ID is required") }); const getRoleResourceAccessParamsSchema = z.strictObject({ roleId: z .string() .transform(Number) .pipe(z.int().positive("Role ID must be a positive integer")), resourceId: z .string() .transform(Number) .pipe(z.int().positive("Resource ID must be a positive integer")) }); const getUserResourceAccessParamsSchema = z.strictObject({ userId: z.string().min(1, "User ID is required"), resourceId: z .string() .transform(Number) .pipe(z.int().positive("Resource ID must be a positive integer")) }); const getResourceRulesParamsSchema = z.strictObject({ resourceId: z .string() .transform(Number) .pipe(z.int().positive("Resource ID must be a positive integer")) }); const validateResourceSessionTokenParamsSchema = z.strictObject({ resourceId: z .string() .transform(Number) .pipe(z.int().positive("Resource ID must be a positive integer")) }); const validateResourceSessionTokenBodySchema = z.strictObject({ token: z.string().min(1, "Token is required") }); const validateResourceAccessTokenBodySchema = z.strictObject({ accessTokenId: z.string().optional(), resourceId: z.number().optional(), accessToken: z.string() }); // Certificates by domains query validation const getCertificatesByDomainsQuerySchema = z.strictObject({ // Accept domains as string or array (domains or domains[]) domains: z .union([z.array(z.string().min(1)), z.string().min(1)]) .optional(), // Handle array format from query parameters (domains[]) "domains[]": z .union([z.array(z.string().min(1)), z.string().min(1)]) .optional() }); // Type exports for request schemas export type GetResourceByDomainParams = z.infer< typeof getResourceByDomainParamsSchema >; export type GetUserSessionParams = z.infer; export type GetUserOrgRoleParams = z.infer; export type GetRoleResourceAccessParams = z.infer< typeof getRoleResourceAccessParamsSchema >; export type GetUserResourceAccessParams = z.infer< typeof getUserResourceAccessParamsSchema >; export type GetResourceRulesParams = z.infer< typeof getResourceRulesParamsSchema >; export type ValidateResourceSessionTokenParams = z.infer< typeof validateResourceSessionTokenParamsSchema >; export type ValidateResourceSessionTokenBody = z.infer< typeof validateResourceSessionTokenBodySchema >; // Type definitions for API responses export type ResourceWithAuth = { resource: Resource | null; pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; org: Org; }; export type UserSessionWithUser = { session: Session | null; user: User | null; }; // Root routes export const hybridRouter = Router(); hybridRouter.use(verifySessionRemoteExitNodeMiddleware); // TODO: ADD RATE LIMITING TO THESE ROUTES AS NEEDED BASED ON USAGE PATTERNS hybridRouter.get( "/general-config", async (req: Request, res: Response, next: NextFunction) => { return response(res, { data: { resource_session_request_param: config.getRawConfig().server.resource_session_request_param, resource_access_token_headers: config.getRawConfig().server.resource_access_token_headers, resource_access_token_param: config.getRawConfig().server.resource_access_token_param, session_cookie_name: config.getRawConfig().server.session_cookie_name, require_email_verification: config.getRawConfig().flags?.require_email_verification || false, resource_session_length_hours: config.getRawConfig().server.resource_session_length_hours }, success: true, error: false, message: "General config retrieved successfully", status: HttpCode.OK }); } ); hybridRouter.get( "/traefik-config", async (req: Request, res: Response, next: NextFunction) => { const remoteExitNode = req.remoteExitNode; if (!remoteExitNode || !remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } try { const traefikConfig = await getTraefikConfig( remoteExitNode.exitNodeId, ["newt", "local", "wireguard"], // Allow them to use all the site types true, // But don't allow domain namespace resources false, // Dont include login pages, true, // allow raw resources false // dont generate maintenance page ); return response(res, { data: traefikConfig, success: true, error: false, message: "Traefik config retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get Traefik config" ) ); } } ); let encryptionKeyHex = ""; let encryptionKey: Buffer; function loadEncryptData() { if (encryptionKey) { return; // already loaded } encryptionKeyHex = privateConfig.getRawPrivateConfig().server.encryption_key; encryptionKey = Buffer.from(encryptionKeyHex, "hex"); } // Get valid certificates for given domains (supports wildcard certs) hybridRouter.get( "/certificates/domains", async (req: Request, res: Response, next: NextFunction) => { try { loadEncryptData(); // Ensure encryption key is loaded const parsed = getCertificatesByDomainsQuerySchema.safeParse( req.query ); if (!parsed.success) { logger.info("Invalid query parameters:", parsed.error); return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsed.error).toString() ) ); } const remoteExitNode = req.remoteExitNode; if (!remoteExitNode || !remoteExitNode.exitNodeId) { logger.error("Remote exit node not found"); return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } // Normalize domains into a unique array const rawDomains = parsed.data.domains; const rawDomainsArray = parsed.data["domains[]"]; // Combine both possible sources const allRawDomains = [ ...(Array.isArray(rawDomains) ? rawDomains : rawDomains ? [rawDomains] : []), ...(Array.isArray(rawDomainsArray) ? rawDomainsArray : rawDomainsArray ? [rawDomainsArray] : []) ]; const uniqueDomains = Array.from( new Set( allRawDomains .map((d) => (typeof d === "string" ? d.trim() : "")) .filter((d) => d.length > 0) ) ); if (uniqueDomains.length === 0) { return response(res, { data: [], success: true, error: false, message: "No domains provided", status: HttpCode.OK }); } // Build candidate domain list: exact + first-suffix for wildcard lookup const suffixes = uniqueDomains .map((domain) => { const firstDot = domain.indexOf("."); return firstDot > 0 ? domain.slice(firstDot + 1) : null; }) .filter((d): d is string => !!d); const candidateDomains = Array.from( new Set([...uniqueDomains, ...suffixes]) ); // Query certificates with domain and org information to check authorization const certRows = await db .select({ id: certificates.certId, domain: certificates.domain, certFile: certificates.certFile, keyFile: certificates.keyFile, expiresAt: certificates.expiresAt, updatedAt: certificates.updatedAt, wildcard: certificates.wildcard, domainId: certificates.domainId, orgId: orgDomains.orgId }) .from(certificates) .leftJoin(domains, eq(domains.domainId, certificates.domainId)) .leftJoin(orgDomains, eq(orgDomains.domainId, domains.domainId)) .where( and( eq(certificates.status, "valid"), isNotNull(certificates.certFile), isNotNull(certificates.keyFile), inArray(certificates.domain, candidateDomains) ) ); // Filter certificates based on wildcard matching and exit node authorization const filtered = []; for (const cert of certRows) { // Check if the domain matches our request const domainMatches = uniqueDomains.includes(cert.domain) || (cert.wildcard === true && uniqueDomains.some((d) => d.endsWith(`.${cert.domain}`) )); if (!domainMatches) { continue; } // Check if the exit node has access to the org that owns this domain if (cert.orgId) { const hasAccess = await checkExitNodeOrg( remoteExitNode.exitNodeId, cert.orgId ); if (hasAccess) { // checkExitNodeOrg returns true when access is denied continue; } } filtered.push(cert); } const result = filtered.map((cert) => { // Decrypt and save certificate file const decryptedCert = decryptData( cert.certFile!, // is not null from query encryptionKey ); // Decrypt and save key file const decryptedKey = decryptData(cert.keyFile!, encryptionKey); // Return only the certificate data without org information return { id: cert.id, domain: cert.domain, certFile: decryptedCert, keyFile: decryptedKey, expiresAt: cert.expiresAt, updatedAt: cert.updatedAt, wildcard: cert.wildcard }; }); return response(res, { data: result, success: true, error: false, message: "Certificates retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get certificates for domains" ) ); } } ); // Get resource by domain with pincode and password information hybridRouter.get( "/resource/domain/:domain", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getResourceByDomainParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { domain } = parsedParams.data; const remoteExitNode = req.remoteExitNode; if (!remoteExitNode?.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const [result] = await db .select() .from(resources) .leftJoin( resourcePincode, eq(resourcePincode.resourceId, resources.resourceId) ) .leftJoin( resourcePassword, eq(resourcePassword.resourceId, resources.resourceId) ) .leftJoin( resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) .leftJoin( resourceHeaderAuthExtendedCompatibility, eq( resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId ) ) .innerJoin(orgs, eq(orgs.orgId, resources.orgId)) .where(eq(resources.fullDomain, domain)) .limit(1); if ( await checkExitNodeOrg( remoteExitNode.exitNodeId, result.resources.orgId ) ) { // If the exit node is not allowed for the org, return an error return next( createHttpError( HttpCode.FORBIDDEN, "Exit node not allowed for this organization" ) ); } if (!result) { return response(res, { data: null, success: true, error: false, message: "Resource not found", status: HttpCode.OK }); } const resourceWithAuth: ResourceWithAuth = { resource: result.resources, pincode: result.resourcePincode, password: result.resourcePassword, headerAuth: result.resourceHeaderAuth, headerAuthExtendedCompatibility: result.resourceHeaderAuthExtendedCompatibility, org: result.orgs }; return response(res, { data: resourceWithAuth, success: true, error: false, message: "Resource retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get resource by domain" ) ); } } ); const getOrgLoginPageParamsSchema = z.strictObject({ orgId: z.string().min(1) }); hybridRouter.get( "/org/:orgId/login-page", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getOrgLoginPageParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const remoteExitNode = req.remoteExitNode; if (!remoteExitNode?.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const [result] = await db .select() .from(loginPageOrg) .where(eq(loginPageOrg.orgId, orgId)) .innerJoin( loginPage, eq(loginPageOrg.loginPageId, loginPage.loginPageId) ) .limit(1); if (!result) { return response(res, { data: null, success: true, error: false, message: "Login page not found", status: HttpCode.OK }); } if ( await checkExitNodeOrg( remoteExitNode.exitNodeId, result.loginPageOrg.orgId ) ) { // If the exit node is not allowed for the org, return an error return next( createHttpError( HttpCode.FORBIDDEN, "Exit node not allowed for this organization" ) ); } return response(res, { data: result.loginPage, success: true, error: false, message: "Login page retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get org login page" ) ); } } ); // Get user session with user information hybridRouter.get( "/session/:userSessionId", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getUserSessionParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userSessionId } = parsedParams.data; const remoteExitNode = req.remoteExitNode; if (!remoteExitNode?.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const [res_data] = await db .select() .from(sessions) .leftJoin(users, eq(users.userId, sessions.userId)) .where(eq(sessions.sessionId, userSessionId)); if (!res_data) { return response(res, { data: null, success: true, error: false, message: "Session not found", status: HttpCode.OK }); } // TODO: THIS SEEMS TO BE TERRIBLY INEFFICIENT AND WE CAN FIX WITH SOME KIND OF BETTER SCHEMA!!!!!!!!!!!!!!! // Check if the user belongs to any organization that the exit node has access to if (res_data.user) { const userOrgsResult = await db .select({ orgId: userOrgs.orgId }) .from(userOrgs) .where(eq(userOrgs.userId, res_data.user.userId)); // Check if the exit node has access to any of the user's organizations let hasAccess = false; for (const userOrg of userOrgsResult) { const accessDenied = await checkExitNodeOrg( remoteExitNode.exitNodeId, userOrg.orgId ); if (!accessDenied) { // checkExitNodeOrg returns true when access is denied, false when allowed hasAccess = true; break; } } if (!hasAccess) { return next( createHttpError( HttpCode.FORBIDDEN, "Exit node not authorized to access this user session" ) ); } } const userSessionWithUser: UserSessionWithUser = { session: res_data.session, user: res_data.user }; return response(res, { data: userSessionWithUser, success: true, error: false, message: "Session retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get user session" ) ); } } ); // Get user organization role hybridRouter.get( "/user/:userId/org/:orgId/role", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getUserOrgRoleParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId, orgId } = parsedParams.data; const remoteExitNode = req.remoteExitNode; if (!remoteExitNode || !remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { return next( createHttpError( HttpCode.UNAUTHORIZED, "User is not authorized to access this organization" ) ); } const userOrgRole = await db .select() .from(userOrgs) .where( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) ) .limit(1); const result = userOrgRole.length > 0 ? userOrgRole[0] : null; return response(res, { data: result, success: true, error: false, message: result ? "User org role retrieved successfully" : "User org role not found", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get user org role" ) ); } } ); // Get user organization role hybridRouter.get( "/user/:userId/org/:orgId/session/:sessionId/verify", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getUserOrgSessionVerifySchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId, orgId, sessionId } = parsedParams.data; const remoteExitNode = req.remoteExitNode; if (!remoteExitNode || !remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { return next( createHttpError( HttpCode.UNAUTHORIZED, "User is not authorized to access this organization" ) ); } const accessPolicy = await checkOrgAccessPolicy({ orgId, userId, sessionId }); return response(res, { data: accessPolicy, success: true, error: false, message: "User org access policy retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get user org role" ) ); } } ); // Check if role has access to resource hybridRouter.get( "/role/:roleId/resource/:resourceId/access", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getRoleResourceAccessParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { roleId, resourceId } = parsedParams.data; const remoteExitNode = req.remoteExitNode; if (!remoteExitNode?.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if ( await checkExitNodeOrg( remoteExitNode.exitNodeId, resource.orgId ) ) { // If the exit node is not allowed for the org, return an error return next( createHttpError( HttpCode.FORBIDDEN, "Exit node not allowed for this organization" ) ); } const roleResourceAccess = await db .select() .from(roleResources) .where( and( eq(roleResources.resourceId, resourceId), eq(roleResources.roleId, roleId) ) ) .limit(1); const result = roleResourceAccess.length > 0 ? roleResourceAccess[0] : null; return response(res, { data: result, success: true, error: false, message: result ? "Role resource access retrieved successfully" : "Role resource access not found", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get role resource access" ) ); } } ); // Check if user has direct access to resource hybridRouter.get( "/user/:userId/resource/:resourceId/access", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getUserResourceAccessParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId, resourceId } = parsedParams.data; const remoteExitNode = req.remoteExitNode; if (!remoteExitNode?.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if ( await checkExitNodeOrg( remoteExitNode.exitNodeId, resource.orgId ) ) { // If the exit node is not allowed for the org, return an error return next( createHttpError( HttpCode.FORBIDDEN, "Exit node not allowed for this organization" ) ); } const userResourceAccess = await db .select() .from(userResources) .where( and( eq(userResources.userId, userId), eq(userResources.resourceId, resourceId) ) ) .limit(1); const result = userResourceAccess.length > 0 ? userResourceAccess[0] : null; return response(res, { data: result, success: true, error: false, message: result ? "User resource access retrieved successfully" : "User resource access not found", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get user resource access" ) ); } } ); // Get resource rules for a given resource hybridRouter.get( "/resource/:resourceId/rules", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getResourceRulesParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const remoteExitNode = req.remoteExitNode; if (!remoteExitNode?.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if ( await checkExitNodeOrg( remoteExitNode.exitNodeId, resource.orgId ) ) { // If the exit node is not allowed for the org, return an error return next( createHttpError( HttpCode.FORBIDDEN, "Exit node not allowed for this organization" ) ); } const rules = await db .select() .from(resourceRules) .where(eq(resourceRules.resourceId, resourceId)); // backward compatibility: COUNTRY -> GEOIP // TODO: remove this after a few versions once all exit nodes are updated if ( (remoteExitNode.secondaryVersion && semver.lt(remoteExitNode.secondaryVersion, "1.1.0")) || !remoteExitNode.secondaryVersion ) { for (const rule of rules) { if (rule.match == "COUNTRY") { rule.match = "GEOIP"; } } } logger.debug( `Retrieved ${rules.length} rules for resource ID ${resourceId}: ${JSON.stringify(rules)}` ); return response<(typeof resourceRules.$inferSelect)[]>(res, { data: rules, success: true, error: false, message: "Resource rules retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get resource rules" ) ); } } ); // Validate resource session token hybridRouter.post( "/resource/:resourceId/session/validate", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = validateResourceSessionTokenParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = validateResourceSessionTokenBodySchema.safeParse( req.body ); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { resourceId } = parsedParams.data; const { token } = parsedBody.data; const result = await validateResourceSessionToken( token, resourceId ); return response(res, { data: result, success: true, error: false, message: result.resourceSession ? "Resource session token is valid" : "Resource session token is invalid or expired", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to validate resource session token" ) ); } } ); // Validate resource session token hybridRouter.post( "/resource/:resourceId/access-token/verify", async (req: Request, res: Response, next: NextFunction) => { try { const parsedBody = validateResourceAccessTokenBodySchema.safeParse( req.body ); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { accessToken, resourceId, accessTokenId } = parsedBody.data; const result = await verifyResourceAccessToken({ accessTokenId, accessToken, resourceId }); return response(res, { data: result, success: true, error: false, message: result.valid ? "Resource access token is valid" : "Resource access token is invalid or expired", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to validate resource session token" ) ); } } ); const geoIpLookupParamsSchema = z.object({ ip: z.union([z.ipv4(), z.ipv6()]) }); hybridRouter.get( "/geoip/:ip", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = geoIpLookupParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { ip } = parsedParams.data; if (!maxmindLookup) { return next( createHttpError( HttpCode.SERVICE_UNAVAILABLE, "GeoIP service is not available" ) ); } const result = maxmindLookup.get(ip); if (!result || !result.country) { return next( createHttpError( HttpCode.NOT_FOUND, "GeoIP information not found" ) ); } const { country } = result; logger.debug( `GeoIP lookup successful for IP ${ip}: ${country.iso_code}` ); return response(res, { data: { countryCode: country.iso_code }, success: true, error: false, message: "GeoIP lookup successful", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to validate resource session token" ) ); } } ); const asnIpLookupParamsSchema = z.object({ ip: z.union([z.ipv4(), z.ipv6()]) }); hybridRouter.get( "/asnip/:ip", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = asnIpLookupParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { ip } = parsedParams.data; if (!maxmindAsnLookup) { return next( createHttpError( HttpCode.SERVICE_UNAVAILABLE, "ASNIP service is not available" ) ); } const result = maxmindAsnLookup.get(ip); if (!result || !result.autonomous_system_number) { return next( createHttpError( HttpCode.NOT_FOUND, "ASNIP information not found" ) ); } const { autonomous_system_number } = result; logger.debug( `ASNIP lookup successful for IP ${ip}: ${autonomous_system_number}` ); return response(res, { data: { asn: autonomous_system_number }, success: true, error: false, message: "GeoIP lookup successful", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to validate resource session token" ) ); } } ); // GERBIL ROUTERS const getConfigSchema = z.object({ publicKey: z.string(), endpoint: z.string(), listenPort: z.number() }); hybridRouter.post( "/gerbil/get-config", async (req: Request, res: Response, next: NextFunction) => { try { const remoteExitNode = req.remoteExitNode; if (!remoteExitNode || !remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); if (!exitNode) { return next( createHttpError(HttpCode.BAD_REQUEST, "Exit node not found") ); } const parsedParams = getConfigSchema.safeParse(req.body); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { publicKey, endpoint, listenPort } = parsedParams.data; // update the public key await db .update(exitNodes) .set({ publicKey: publicKey, endpoint: endpoint, listenPort: listenPort }) .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); const configResponse = await generateGerbilConfig(exitNode); return res.status(HttpCode.OK).send(configResponse); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get gerbil config" ) ); } } ); hybridRouter.post( "/gerbil/receive-bandwidth", async (req: Request, res: Response, next: NextFunction) => { try { const remoteExitNode = req.remoteExitNode; if (!remoteExitNode || !remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const bandwidthData: any[] = req.body; if (!Array.isArray(bandwidthData)) { throw new Error("Invalid bandwidth data"); } await updateSiteBandwidth( bandwidthData, false, remoteExitNode.exitNodeId ); // we dont want to check limits return res.status(HttpCode.OK).send({ success: true }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to receive bandwidth data" ) ); } } ); const updateHolePunchSchema = z.object({ olmId: z.string().optional(), newtId: z.string().optional(), token: z.string(), ip: z.string(), port: z.number(), timestamp: z.number(), reachableAt: z.string().optional(), publicKey: z.string() // this is the client public key }); hybridRouter.post( "/gerbil/update-hole-punch", async (req: Request, res: Response, next: NextFunction) => { try { const remoteExitNode = req.remoteExitNode; if (!remoteExitNode || !remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); if (!exitNode) { return next( createHttpError(HttpCode.BAD_REQUEST, "Exit node not found") ); } // Validate request parameters const parsedParams = updateHolePunchSchema.safeParse(req.body); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { olmId, newtId, ip, port, timestamp, token, publicKey, reachableAt } = parsedParams.data; const destinations = await updateAndGenerateEndpointDestinations( olmId, newtId, ip, port, timestamp, token, publicKey, exitNode, true ); return res.status(HttpCode.OK).send({ destinations: destinations }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } ); hybridRouter.post( "/gerbil/get-all-relays", async (req: Request, res: Response, next: NextFunction) => { try { const remoteExitNode = req.remoteExitNode; if (!remoteExitNode || !remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); if (!exitNode) { return next( createHttpError(HttpCode.BAD_REQUEST, "Exit node not found") ); } const mappings = await generateRelayMappings(exitNode); logger.debug( `Returning mappings for ${Object.keys(mappings).length} endpoints` ); return res.status(HttpCode.OK).send({ mappings }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } ); hybridRouter.post("/badger/exchange-session", exchangeSession); const getResolvedHostnameSchema = z.object({ hostname: z.string(), publicKey: z.string() }); hybridRouter.post( "/gerbil/get-resolved-hostname", async (req: Request, res: Response, next: NextFunction) => { try { // Validate request parameters const parsedParams = getResolvedHostnameSchema.safeParse(req.body); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { hostname, publicKey } = parsedParams.data; const remoteExitNode = req.remoteExitNode; if (!remoteExitNode || !remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); if (!exitNode) { return next( createHttpError(HttpCode.BAD_REQUEST, "Exit node not found") ); } const resourceExitNodes = await resolveExitNodes( hostname, publicKey ); if (resourceExitNodes.length === 0) { return res.status(HttpCode.OK).send({ endpoints: [] }); } // Filter endpoints based on exit node authorization // WE DONT WANT SOMEONE TO SEND A REQUEST TO SOMEONE'S // EXIT NODE AND TO FORWARD IT TO ANOTHER'S! const authorizedEndpoints = []; for (const node of resourceExitNodes) { const accessDenied = await checkExitNodeOrg( remoteExitNode.exitNodeId, node.orgId ); if (!accessDenied) { // checkExitNodeOrg returns true when access is denied, false when allowed authorizedEndpoints.push(node.endpoint); } } if (authorizedEndpoints.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, "Exit node not authorized to access this resource" ) ); } const endpoints = authorizedEndpoints; logger.debug( `Returning ${Object.keys(endpoints).length} endpoints: ${JSON.stringify(endpoints)}` ); return res.status(HttpCode.OK).send({ endpoints }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } ); hybridRouter.get( "/org/:orgId/get-retention-days", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getOrgLoginPageParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const remoteExitNode = req.remoteExitNode; if (!remoteExitNode || !remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { // If the exit node is not allowed for the org, return an error return next( createHttpError( HttpCode.FORBIDDEN, "Exit node not allowed for this organization" ) ); } const [org] = await db .select({ settingsLogRetentionDaysRequest: orgs.settingsLogRetentionDaysRequest }) .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); return response(res, { data: { settingsLogRetentionDaysRequest: org.settingsLogRetentionDaysRequest }, success: true, error: false, message: "Log retention days retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } ); const batchLogsSchema = z.object({ logs: z.array( z.object({ timestamp: z.number(), orgId: z.string().optional(), actorType: z.string().optional(), actor: z.string().optional(), actorId: z.string().optional(), metadata: z.string().nullable(), action: z.boolean(), resourceId: z.number().optional(), reason: z.number(), location: z.string().optional(), originalRequestURL: z.string(), scheme: z.string(), host: z.string(), path: z.string(), method: z.string(), ip: z.string().optional(), tls: z.boolean() }) ) }); hybridRouter.post( "/logs/batch", async (req: Request, res: Response, next: NextFunction) => { try { const parsedBody = batchLogsSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { logs } = parsedBody.data; const remoteExitNode = req.remoteExitNode; if (!remoteExitNode || !remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node not found" ) ); } const exitNodeOrgsRes = await db .select() .from(exitNodeOrgs) .where( and(eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId)) ) .limit(1); // Batch insert all logs in a single query const logEntries = logs .filter((logEntry) => { if (!logEntry.orgId) { return false; } const isOrgAllowed = exitNodeOrgsRes.some( (eno) => eno.orgId === logEntry.orgId ); return isOrgAllowed; }) .map((logEntry) => ({ timestamp: logEntry.timestamp, orgId: logEntry.orgId, actorType: logEntry.actorType, actor: logEntry.actor, actorId: logEntry.actorId, metadata: logEntry.metadata, action: logEntry.action, resourceId: logEntry.resourceId, reason: logEntry.reason, location: logEntry.location, // userAgent: data.userAgent, // TODO: add this // headers: data.body.headers, // query: data.body.query, originalRequestURL: logEntry.originalRequestURL, scheme: logEntry.scheme, host: logEntry.host, path: logEntry.path, method: logEntry.method, ip: logEntry.ip, tls: logEntry.tls })); // batch them into inserts of 100 to avoid exceeding parameter limits const batchSize = 100; for (let i = 0; i < logEntries.length; i += batchSize) { const batch = logEntries.slice(i, i + batchSize); await db.insert(requestAuditLog).values(batch); } return response(res, { data: null, success: true, error: false, message: "Logs saved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } ); ================================================ FILE: server/private/routers/integration.ts ================================================ /* * 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 orgIdp from "#private/routers/orgIdp"; import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; import { verifyApiKeyHasAction, verifyApiKeyIsRoot, verifyApiKeyOrgAccess, verifyApiKeyIdpAccess, verifyLimits } from "@server/middlewares"; import { verifyValidSubscription, verifyValidLicense } from "#private/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { unauthenticated as ua, authenticated as a } from "@server/routers/integration"; import { logActionAudit } from "#private/middlewares"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; export const unauthenticated = ua; export const authenticated = a; authenticated.post( `/org/:orgId/send-usage-notification`, verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine verifyApiKeyHasAction(ActionsEnum.sendUsageNotification), logActionAudit(ActionsEnum.sendUsageNotification), org.sendUsageNotification ); authenticated.delete( "/idp/:idpId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdp), logActionAudit(ActionsEnum.deleteIdp), orgIdp.deleteOrgIdp ); authenticated.get( "/org/:orgId/logs/action", verifyValidLicense, verifyValidSubscription(tierMatrix.actionLogs), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logs.queryActionAuditLogs ); authenticated.get( "/org/:orgId/logs/action/export", verifyValidLicense, verifyValidSubscription(tierMatrix.logExport), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), logs.exportActionAuditLogs ); authenticated.get( "/org/:orgId/logs/access", verifyValidLicense, verifyValidSubscription(tierMatrix.accessLogs), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logs.queryAccessAuditLogs ); authenticated.get( "/org/:orgId/logs/access/export", verifyValidLicense, verifyValidSubscription(tierMatrix.logExport), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), logs.exportAccessAuditLogs ); authenticated.put( "/org/:orgId/idp/oidc", verifyValidLicense, verifyValidSubscription(tierMatrix.orgOidc), verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createIdp), logActionAudit(ActionsEnum.createIdp), orgIdp.createOrgOidcIdp ); authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, verifyValidSubscription(tierMatrix.orgOidc), verifyApiKeyOrgAccess, verifyApiKeyIdpAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateIdp), logActionAudit(ActionsEnum.updateIdp), orgIdp.updateOrgOidcIdp ); authenticated.delete( "/org/:orgId/idp/:idpId", verifyValidLicense, verifyApiKeyOrgAccess, verifyApiKeyIdpAccess, verifyApiKeyHasAction(ActionsEnum.deleteIdp), logActionAudit(ActionsEnum.deleteIdp), orgIdp.deleteOrgIdp ); authenticated.get( "/org/:orgId/idp/:idpId", verifyValidLicense, verifyApiKeyOrgAccess, verifyApiKeyIdpAccess, verifyApiKeyHasAction(ActionsEnum.getIdp), orgIdp.getOrgIdp ); authenticated.get( "/org/:orgId/idp", verifyValidLicense, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listIdps), orgIdp.listOrgIdps ); ================================================ FILE: server/private/routers/internal.ts ================================================ /* * 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 loginPage from "#private/routers/loginPage"; import * as auth from "#private/routers/auth"; import * as orgIdp from "#private/routers/orgIdp"; import * as billing from "#private/routers/billing"; import * as license from "#private/routers/license"; import * as resource from "#private/routers/resource"; import { verifySessionUserMiddleware } from "@server/middlewares"; import { internalRouter as ir } from "@server/routers/internal"; export const internalRouter = ir; internalRouter.get("/org/:orgId/idp", orgIdp.listOrgIdps); internalRouter.get("/org/:orgId/billing/tier", billing.getOrgTier); internalRouter.get("/login-page", loginPage.loadLoginPage); internalRouter.get("/login-page-branding", loginPage.loadLoginPageBranding); internalRouter.post( "/get-session-transfer-token", verifySessionUserMiddleware, auth.getSessionTransferToken ); internalRouter.get(`/license/status`, license.getLicenseStatus); internalRouter.get("/maintenance/info", resource.getMaintenanceInfo); ================================================ FILE: server/private/routers/license/activateLicense.ts ================================================ /* * 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import license from "#private/license/license"; import { z } from "zod"; import { fromError } from "zod-validation-error"; const bodySchema = z.strictObject({ licenseKey: z.string().min(1).max(255) }); export async function activateLicense( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { licenseKey } = parsedBody.data; try { const status = await license.activateLicenseKey(licenseKey); return sendResponse(res, { data: status, success: true, error: false, message: "License key activated successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`) ); } } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/license/deleteLicenseKey.ts ================================================ /* * 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; import { licenseKey } from "@server/db"; import license from "#private/license/license"; const paramsSchema = z.strictObject({ licenseKey: z.string().min(1).max(255) }); export async function deleteLicenseKey( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { licenseKey: key } = parsedParams.data; const [existing] = await db .select() .from(licenseKey) .where(eq(licenseKey.licenseKeyId, key)) .limit(1); if (!existing) { return next( createHttpError( HttpCode.NOT_FOUND, `License key ${key} not found` ) ); } await db.delete(licenseKey).where(eq(licenseKey.licenseKeyId, key)); const status = await license.forceRecheck(); return sendResponse(res, { data: status, success: true, error: false, message: "License key deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/license/getLicenseStatus.ts ================================================ /* * 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import license from "#private/license/license"; import { GetLicenseStatusResponse } from "@server/routers/license/types"; export async function getLicenseStatus( req: Request, res: Response, next: NextFunction ): Promise { try { const status = await license.check(); return sendResponse(res, { data: status, success: true, error: false, message: "Got status", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/license/index.ts ================================================ /* * 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 "./getLicenseStatus"; export * from "./activateLicense"; export * from "./listLicenseKeys"; export * from "./deleteLicenseKey"; export * from "./recheckStatus"; ================================================ FILE: server/private/routers/license/listLicenseKeys.ts ================================================ /* * 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import license from "#private/license/license"; import { ListLicenseKeysResponse } from "@server/routers/license/types"; export async function listLicenseKeys( req: Request, res: Response, next: NextFunction ): Promise { try { const keys = license.listKeys(); return sendResponse(res, { data: keys, success: true, error: false, message: "Successfully retrieved license keys", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/license/recheckStatus.ts ================================================ /* * 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import license from "#private/license/license"; export async function recheckStatus( req: Request, res: Response, next: NextFunction ): Promise { try { try { const status = await license.forceRecheck(); return sendResponse(res, { data: status, success: true, error: false, message: "License status rechecked successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`) ); } } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/loginPage/createLoginPage.ts ================================================ /* * 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, exitNodes, loginPage, LoginPage, loginPageOrg, resources, sites } 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, and } from "drizzle-orm"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { createCertificate } from "#private/routers/certificates/createCertificate"; import { CreateLoginPageResponse } from "@server/routers/loginPage/types"; const paramsSchema = z.strictObject({ orgId: z.string() }); const bodySchema = z.strictObject({ subdomain: z.string().nullable().optional(), domainId: z.string() }); export type CreateLoginPageBody = z.infer; export async function createLoginPage( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { domainId, subdomain } = parsedBody.data; const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const [existing] = await db .select() .from(loginPageOrg) .where(eq(loginPageOrg.orgId, orgId)); if (existing) { return next( createHttpError( HttpCode.BAD_REQUEST, "A login page already exists for this organization" ) ); } const domainResult = await validateAndConstructDomain( domainId, orgId, subdomain ); if (!domainResult.success) { return next( createHttpError(HttpCode.BAD_REQUEST, domainResult.error) ); } const { fullDomain, subdomain: finalSubdomain } = domainResult; logger.debug(`Full domain: ${fullDomain}`); const existingResource = await db .select() .from(resources) .where(eq(resources.fullDomain, fullDomain)); if (existingResource.length > 0) { return next( createHttpError( HttpCode.CONFLICT, "Resource with that domain already exists" ) ); } const existingLoginPages = await db .select() .from(loginPage) .where(eq(loginPage.fullDomain, fullDomain)); if (existingLoginPages.length > 0) { return next( createHttpError( HttpCode.CONFLICT, "Login page with that domain already exists" ) ); } let returned: LoginPage | undefined; await db.transaction(async (trx) => { const orgSites = await trx .select() .from(sites) .innerJoin( exitNodes, eq(exitNodes.exitNodeId, sites.exitNodeId) ) .where( and( eq(sites.orgId, orgId), eq(exitNodes.type, "gerbil"), eq(exitNodes.online, true) ) ) .limit(10); let exitNodesList = orgSites.map((s) => s.exitNodes); if (exitNodesList.length === 0) { exitNodesList = await trx .select() .from(exitNodes) .where( and( eq(exitNodes.type, "gerbil"), eq(exitNodes.online, true) ) ) .limit(10); } // select a random exit node const randomExitNode = exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; if (!randomExitNode) { throw new Error("No exit nodes available"); } const [returnedLoginPage] = await db .insert(loginPage) .values({ subdomain: finalSubdomain, fullDomain, domainId, exitNodeId: randomExitNode.exitNodeId }) .returning(); await trx.insert(loginPageOrg).values({ orgId, loginPageId: returnedLoginPage.loginPageId }); returned = returnedLoginPage; }); if (!returned) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create login page" ) ); } await createCertificate(domainId, fullDomain, db); return response(res, { data: returned, success: true, error: false, message: "Login page created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/loginPage/deleteLoginPage.ts ================================================ /* * 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, loginPage, LoginPage, loginPageOrg } 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, and } from "drizzle-orm"; import { DeleteLoginPageResponse } from "@server/routers/loginPage/types"; const paramsSchema = z .object({ orgId: z.string(), loginPageId: z.coerce.number() }) .strict(); export async function deleteLoginPage( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const [existingLoginPage] = await db .select() .from(loginPage) .where(eq(loginPage.loginPageId, parsedParams.data.loginPageId)) .innerJoin( loginPageOrg, eq(loginPageOrg.orgId, parsedParams.data.orgId) ); if (!existingLoginPage) { return next( createHttpError(HttpCode.NOT_FOUND, "Login page not found") ); } await db .delete(loginPageOrg) .where( and( eq(loginPageOrg.orgId, parsedParams.data.orgId), eq(loginPageOrg.loginPageId, parsedParams.data.loginPageId) ) ); // const leftoverLinks = await db // .select() // .from(loginPageOrg) // .where(eq(loginPageOrg.loginPageId, parsedParams.data.loginPageId)) // .limit(1); // if (!leftoverLinks.length) { await db .delete(loginPage) .where(eq(loginPage.loginPageId, parsedParams.data.loginPageId)); await db .delete(loginPageOrg) .where(eq(loginPageOrg.loginPageId, parsedParams.data.loginPageId)); // } return response(res, { data: existingLoginPage.loginPage, success: true, error: false, message: "Login page deleted successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/loginPage/deleteLoginPageBranding.ts ================================================ /* * 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, LoginPageBranding, loginPageBranding, loginPageBrandingOrg } 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"; const paramsSchema = z .object({ orgId: z.string() }) .strict(); export async function deleteLoginPageBranding( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const [existingLoginPageBranding] = await db .select() .from(loginPageBranding) .innerJoin( loginPageBrandingOrg, eq( loginPageBrandingOrg.loginPageBrandingId, loginPageBranding.loginPageBrandingId ) ) .where(eq(loginPageBrandingOrg.orgId, orgId)); if (!existingLoginPageBranding) { return next( createHttpError( HttpCode.NOT_FOUND, "Login page branding not found" ) ); } await db .delete(loginPageBranding) .where( eq( loginPageBranding.loginPageBrandingId, existingLoginPageBranding.loginPageBranding .loginPageBrandingId ) ); return response(res, { data: existingLoginPageBranding.loginPageBranding, success: true, error: false, message: "Login page branding deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/loginPage/getLoginPage.ts ================================================ /* * 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, loginPage, loginPageOrg } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { GetLoginPageResponse } from "@server/routers/loginPage/types"; const paramsSchema = z.strictObject({ orgId: z.string() }); async function query(orgId: string) { const [res] = await db .select() .from(loginPageOrg) .where(eq(loginPageOrg.orgId, orgId)) .innerJoin( loginPage, eq(loginPage.loginPageId, loginPageOrg.loginPageId) ) .limit(1); return res?.loginPage; } export async function getLoginPage( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const loginPage = await query(orgId); if (!loginPage) { return next( createHttpError(HttpCode.NOT_FOUND, "Login page not found") ); } return response(res, { data: loginPage, success: true, error: false, message: "Login page retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/loginPage/getLoginPageBranding.ts ================================================ /* * 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, LoginPageBranding, loginPageBranding, loginPageBrandingOrg } 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"; const paramsSchema = z.strictObject({ orgId: z.string() }); export async function getLoginPageBranding( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const [existingLoginPageBranding] = await db .select() .from(loginPageBranding) .innerJoin( loginPageBrandingOrg, eq( loginPageBrandingOrg.loginPageBrandingId, loginPageBranding.loginPageBrandingId ) ) .where(eq(loginPageBrandingOrg.orgId, orgId)); if (!existingLoginPageBranding) { return next( createHttpError( HttpCode.NOT_FOUND, "Login page branding not found" ) ); } return response(res, { data: existingLoginPageBranding.loginPageBranding, success: true, error: false, message: "Login page branding retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/loginPage/index.ts ================================================ /* * 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 "./createLoginPage"; export * from "./updateLoginPage"; export * from "./getLoginPage"; export * from "./loadLoginPage"; export * from "./updateLoginPage"; export * from "./deleteLoginPage"; export * from "./upsertLoginPageBranding"; export * from "./deleteLoginPageBranding"; export * from "./getLoginPageBranding"; export * from "./loadLoginPageBranding"; ================================================ FILE: server/private/routers/loginPage/loadLoginPage.ts ================================================ /* * 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, idpOrg, loginPage, loginPageOrg, resources } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { LoadLoginPageResponse } from "@server/routers/loginPage/types"; const querySchema = z.object({ resourceId: z.coerce.number().int().positive().optional(), idpId: z.coerce.number().int().positive().optional(), orgId: z.string().min(1).optional(), fullDomain: z.string().min(1) }); async function query(orgId: string | undefined, fullDomain: string) { if (!orgId) { const [res] = await db .select() .from(loginPage) .where(eq(loginPage.fullDomain, fullDomain)) .innerJoin( loginPageOrg, eq(loginPage.loginPageId, loginPageOrg.loginPageId) ) .limit(1); if (!res) { return null; } return { ...res.loginPage, orgId: res.loginPageOrg.orgId }; } const [orgLink] = await db .select() .from(loginPageOrg) .where(eq(loginPageOrg.orgId, orgId)); if (!orgLink) { return null; } const [res] = await db .select() .from(loginPage) .where( and( eq(loginPage.loginPageId, orgLink.loginPageId), eq(loginPage.fullDomain, fullDomain) ) ) .limit(1); if (!res) { return null; } return { ...res, orgId: orgLink.orgId }; } export async function loadLoginPage( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { resourceId, idpId, fullDomain } = parsedQuery.data; let orgId: string | undefined = undefined; if (resourceId) { const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } orgId = resource.orgId; } else if (idpId) { const [idpOrgLink] = await db .select() .from(idpOrg) .where(eq(idpOrg.idpId, idpId)); if (!idpOrgLink) { return next( createHttpError(HttpCode.NOT_FOUND, "IdP not found") ); } orgId = idpOrgLink.orgId; } else if (parsedQuery.data.orgId) { orgId = parsedQuery.data.orgId; } const loginPage = await query(orgId, fullDomain); if (!loginPage) { return next( createHttpError(HttpCode.NOT_FOUND, "Login page not found") ); } return response(res, { data: loginPage, success: true, error: false, message: "Login page retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/loginPage/loadLoginPageBranding.ts ================================================ /* * 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, loginPageBranding, loginPageBrandingOrg, orgs } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import type { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types"; const querySchema = z.object({ orgId: z.string().min(1) }); async function query(orgId: string) { const [orgLink] = await db .select() .from(loginPageBrandingOrg) .where(eq(loginPageBrandingOrg.orgId, orgId)) .innerJoin(orgs, eq(loginPageBrandingOrg.orgId, orgs.orgId)); if (!orgLink) { return null; } const [res] = await db .select() .from(loginPageBranding) .where( and( eq( loginPageBranding.loginPageBrandingId, orgLink.loginPageBrandingOrg.loginPageBrandingId ) ) ) .limit(1); if (!res) { return null; } return { ...res, orgId: orgLink.orgs.orgId, orgName: orgLink.orgs.name }; } export async function loadLoginPageBranding( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { orgId } = parsedQuery.data; const branding = await query(orgId); if (!branding) { return next( createHttpError( HttpCode.NOT_FOUND, "Branding for Login page not found" ) ); } return response(res, { data: branding, success: true, error: false, message: "Login page branding retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/loginPage/updateLoginPage.ts ================================================ /* * 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, loginPage, LoginPage, loginPageOrg, resources } 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, and } from "drizzle-orm"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { subdomainSchema } from "@server/lib/schemas"; import { createCertificate } from "#private/routers/certificates/createCertificate"; import { UpdateLoginPageResponse } from "@server/routers/loginPage/types"; const paramsSchema = z .object({ orgId: z.string(), loginPageId: z.coerce.number() }) .strict(); const bodySchema = z .strictObject({ subdomain: subdomainSchema.nullable().optional(), domainId: z.string().optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" }) .refine( (data) => { if (data.subdomain) { return subdomainSchema.safeParse(data.subdomain).success; } return true; }, { error: "Invalid subdomain" } ); export type UpdateLoginPageBody = z.infer; export async function updateLoginPage( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const updateData = parsedBody.data; const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { loginPageId, orgId } = parsedParams.data; const [existingLoginPage] = await db .select() .from(loginPage) .where(eq(loginPage.loginPageId, loginPageId)); if (!existingLoginPage) { return next( createHttpError(HttpCode.NOT_FOUND, "Login page not found") ); } const [orgLink] = await db .select() .from(loginPageOrg) .where( and( eq(loginPageOrg.orgId, orgId), eq(loginPageOrg.loginPageId, loginPageId) ) ); if (!orgLink) { return next( createHttpError( HttpCode.NOT_FOUND, "Login page not found for this organization" ) ); } if (updateData.domainId) { const domainId = updateData.domainId; // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, orgId, updateData.subdomain ); if (!domainResult.success) { return next( createHttpError(HttpCode.BAD_REQUEST, domainResult.error) ); } const { fullDomain, subdomain: finalSubdomain } = domainResult; logger.debug(`Full domain: ${fullDomain}`); if (fullDomain) { const [existingDomain] = await db .select() .from(resources) .where(eq(resources.fullDomain, fullDomain)); if (existingDomain) { return next( createHttpError( HttpCode.CONFLICT, "Resource with that domain already exists" ) ); } const [existingLoginPage] = await db .select() .from(loginPage) .where(eq(loginPage.fullDomain, fullDomain)); if ( existingLoginPage && existingLoginPage.loginPageId !== loginPageId ) { return next( createHttpError( HttpCode.CONFLICT, "Login page with that domain already exists" ) ); } // update the full domain if it has changed if ( fullDomain && fullDomain !== existingLoginPage?.fullDomain ) { await db .update(loginPage) .set({ fullDomain }) .where(eq(loginPage.loginPageId, loginPageId)); } await createCertificate(domainId, fullDomain, db); } updateData.subdomain = finalSubdomain; } const updatedLoginPage = await db .update(loginPage) .set({ ...updateData }) .where(eq(loginPage.loginPageId, loginPageId)) .returning(); if (updatedLoginPage.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Login page with ID ${loginPageId} not found` ) ); } return response(res, { data: updatedLoginPage[0], success: true, error: false, message: "Login page created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/loginPage/upsertLoginPageBranding.ts ================================================ /* * 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, LoginPageBranding, loginPageBranding, loginPageBrandingOrg } 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, 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({ orgId: z.string() }); const bodySchema = z.strictObject({ logoUrl: z .union([ z.literal(""), z .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(urlOrPath, { method: "HEAD" }).catch(() => { // If HEAD fails (CORS or method not allowed), try GET return fetch(urlOrPath, { method: "GET" }); }); if (response.status !== 200) { ctx.addIssue({ code: "custom", message: `Failed to load image. Please check that the URL is accessible.` }); return; } const contentType = response.headers.get("content-type") ?? ""; if (!contentType.startsWith("image/")) { ctx.addIssue({ code: "custom", message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).` }); return; } } catch (error) { let errorMessage = "Unable to verify image URL. Please check that the URL is accessible and points to an image file."; if (error instanceof TypeError && error.message.includes("fetch")) { errorMessage = "Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct."; } else if (error instanceof Error) { errorMessage = `Error verifying URL: ${error.message}`; } ctx.addIssue({ code: "custom", message: errorMessage }); } }) ]) .transform((val) => (val === "" ? null : val)) .nullish(), logoWidth: z.coerce.number().min(1), logoHeight: z.coerce.number().min(1), resourceTitle: z.string(), resourceSubtitle: z.string().optional(), orgTitle: z.string().optional(), orgSubtitle: z.string().optional(), primaryColor: z .string() .regex(/^#([0-9a-f]{6}|[0-9a-f]{3})$/i) .optional() }); export type UpdateLoginPageBrandingBody = z.infer; export async function upsertLoginPageBranding( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = await bodySchema.safeParseAsync(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; let updateData = parsedBody.data satisfies InferInsertModel< typeof loginPageBranding >; // Empty strings are transformed to null by the schema, which will clear the logo URL in the database // We keep it as null (not undefined) because undefined fields are omitted from Drizzle updates if ( build !== "saas" && !config.getRawPrivateConfig().flags.use_org_only_idp ) { const { orgTitle, orgSubtitle, ...rest } = updateData; updateData = rest; } const [existingLoginPageBranding] = await db .select() .from(loginPageBranding) .innerJoin( loginPageBrandingOrg, eq( loginPageBrandingOrg.loginPageBrandingId, loginPageBranding.loginPageBrandingId ) ) .where(eq(loginPageBrandingOrg.orgId, orgId)); let updatedLoginPageBranding: LoginPageBranding; if (existingLoginPageBranding) { updatedLoginPageBranding = await db.transaction(async (tx) => { const [branding] = await tx .update(loginPageBranding) .set({ ...updateData }) .where( eq( loginPageBranding.loginPageBrandingId, existingLoginPageBranding.loginPageBranding .loginPageBrandingId ) ) .returning(); return branding; }); } else { updatedLoginPageBranding = await db.transaction(async (tx) => { const [branding] = await tx .insert(loginPageBranding) .values({ ...updateData }) .returning(); await tx.insert(loginPageBrandingOrg).values({ loginPageBrandingId: branding.loginPageBrandingId, orgId: orgId }); return branding; }); } return response(res, { data: updatedLoginPageBranding, success: true, error: false, message: existingLoginPageBranding ? "Login page branding updated successfully" : "Login page branding created successfully", status: existingLoginPageBranding ? HttpCode.OK : HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/misc/index.ts ================================================ /* * 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 "./sendSupportEmail"; ================================================ FILE: server/private/routers/misc/sendSupportEmail.ts ================================================ /* * 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { sendEmail } from "@server/emails"; import SupportEmail from "@server/emails/templates/SupportEmail"; import config from "@server/lib/config"; const bodySchema = z.strictObject({ body: z.string().min(1), subject: z.string().min(1).max(255) }); export async function sendSupportEmail( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { body, subject } = parsedBody.data; const user = req.user!; if (!user?.email) { return next( createHttpError( HttpCode.BAD_REQUEST, "User does not have an email associated with their account" ) ); } try { await sendEmail( SupportEmail({ username: user.username, email: user.email, subject, body }), { name: req.user?.email || "Support User", to: "support@pangolin.net", replyTo: req.user?.email || undefined, from: config.getNoReplyEmail(), subject: `Support Request: ${subject}` } ); return sendResponse(res, { data: {}, success: true, error: false, message: "Sent support email successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`) ); } } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/org/index.ts ================================================ /* * 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 "./sendUsageNotifications"; ================================================ FILE: server/private/routers/org/sendUsageNotifications.ts ================================================ /* * 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 } from "@server/db"; import { userOrgs, users, roles, orgs } from "@server/db"; import { eq, and, or } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { sendEmail } from "@server/emails"; import NotifyUsageLimitApproaching from "@server/emails/templates/NotifyUsageLimitApproaching"; import NotifyUsageLimitReached from "@server/emails/templates/NotifyUsageLimitReached"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; const sendUsageNotificationParamsSchema = z.object({ orgId: z.string() }); const sendUsageNotificationBodySchema = z.object({ notificationType: z.enum(["approaching_70", "approaching_90", "reached"]), limitName: z.string(), currentUsage: z.number(), usageLimit: z.number() }); type SendUsageNotificationRequest = z.infer< typeof sendUsageNotificationBodySchema >; export type SendUsageNotificationResponse = { success: boolean; emailsSent: number; adminEmails: string[]; }; // WE SHOULD NOT REGISTER THE PATH IN SAAS // registry.registerPath({ // method: "post", // path: "/org/{orgId}/send-usage-notification", // description: "Send usage limit notification emails to all organization admins.", // tags: [OpenAPITags.Org], // request: { // params: sendUsageNotificationParamsSchema, // body: { // content: { // "application/json": { // schema: sendUsageNotificationBodySchema // } // } // } // }, // responses: { // 200: { // description: "Usage notifications sent successfully", // content: { // "application/json": { // schema: z.object({ // success: z.boolean(), // emailsSent: z.number(), // adminEmails: z.array(z.string()) // }) // } // } // } // } // }); async function getOrgAdmins(orgId: string) { // Get all users in the organization who are either: // 1. Organization owners (isOwner = true) // 2. Have admin roles (role.isAdmin = true) const admins = await db .select({ userId: users.userId, email: users.email, name: users.name, isOwner: userOrgs.isOwner, roleName: roles.name, isAdminRole: roles.isAdmin }) .from(userOrgs) .innerJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .where( and( eq(userOrgs.orgId, orgId), or(eq(userOrgs.isOwner, true), eq(roles.isAdmin, true)) ) ); // Filter to only include users with verified emails const orgAdmins = admins.filter( (admin) => admin.email && admin.email.length > 0 ); return orgAdmins; } export async function sendUsageNotification( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = sendUsageNotificationParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = sendUsageNotificationBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { orgId } = parsedParams.data; const { notificationType, limitName, currentUsage, usageLimit } = parsedBody.data; // Verify organization exists const org = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (org.length === 0) { return next( createHttpError(HttpCode.NOT_FOUND, "Organization not found") ); } // Get all admin users for this organization const orgAdmins = await getOrgAdmins(orgId); if (orgAdmins.length === 0) { logger.warn(`No admin users found for organization ${orgId}`); return response(res, { data: { success: true, emailsSent: 0, adminEmails: [] }, success: true, error: false, message: "No admin users found to notify", status: HttpCode.OK }); } // Default billing link if not provided const defaultBillingLink = `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing`; let emailsSent = 0; const adminEmails: string[] = []; // Send emails to all admin users for (const admin of orgAdmins) { if (!admin.email) continue; try { let template; let subject; if ( notificationType === "approaching_70" || notificationType === "approaching_90" ) { template = NotifyUsageLimitApproaching({ email: admin.email, limitName, currentUsage, usageLimit, billingLink: defaultBillingLink }); subject = `Usage limit warning for ${limitName}`; } else { template = NotifyUsageLimitReached({ email: admin.email, limitName, currentUsage, usageLimit, billingLink: defaultBillingLink }); subject = `URGENT: Usage limit reached for ${limitName}`; } await sendEmail(template, { to: admin.email, from: config.getNoReplyEmail(), subject }); emailsSent++; adminEmails.push(admin.email); logger.info( `Usage notification sent to admin ${admin.email} for org ${orgId}` ); } catch (emailError) { logger.error( `Failed to send usage notification to ${admin.email}:`, emailError ); // Continue with other admins even if one fails } } return response(res, { data: { success: true, emailsSent, adminEmails }, success: true, error: false, message: `Usage notifications sent to ${emailsSent} administrators`, status: HttpCode.OK }); } catch (error) { logger.error("Error sending usage notifications:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to send usage notifications" ) ); } } ================================================ FILE: server/private/routers/orgIdp/createOrgOidcIdp.ts ================================================ /* * 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 } 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 { OpenAPITags, registry } from "@server/openApi"; import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; import { isSubscribed } from "#private/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import privateConfig from "#private/lib/config"; import { build } from "@server/build"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); const bodySchema = z.strictObject({ name: z.string().nonempty(), clientId: z.string().nonempty(), clientSecret: z.string().nonempty(), authUrl: z.url(), tokenUrl: z.url(), identifierPath: z.string().nonempty(), emailPath: z.string().optional(), namePath: z.string().optional(), scopes: z.string().nonempty(), autoProvision: z.boolean().optional(), variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"), roleMapping: z.string().optional(), tags: z.string().optional() }); registry.registerPath({ method: "put", path: "/org/{orgId}/idp/oidc", description: "Create an OIDC IdP for a specific organization.", tags: [OpenAPITags.OrgIdp], request: { params: paramsSchema, body: { content: { "application/json": { schema: bodySchema } } } }, responses: {} }); export async function createOrgOidcIdp( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } if ( privateConfig.getRawPrivateConfig().app.identity_provider_mode !== "org" ) { return next( createHttpError( HttpCode.BAD_REQUEST, "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." ) ); } const { clientId, clientSecret, authUrl, tokenUrl, scopes, identifierPath, emailPath, namePath, name, variant, roleMapping, tags } = parsedBody.data; let { autoProvision } = parsedBody.data; if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted const subscribed = await isSubscribed( orgId, tierMatrix.deviceApprovals ); if (!subscribed) { autoProvision = false; } } const key = config.getRawConfig().server.secret!; const encryptedSecret = encrypt(clientSecret, key); const encryptedClientId = encrypt(clientId, key); let idpId: number | undefined; await db.transaction(async (trx) => { const [idpRes] = await trx .insert(idp) .values({ name, autoProvision, type: "oidc", tags }) .returning(); idpId = idpRes.idpId; await trx.insert(idpOidcConfig).values({ idpId: idpRes.idpId, clientId: encryptedClientId, clientSecret: encryptedSecret, authUrl, tokenUrl, scopes, identifierPath, emailPath, namePath, variant }); await trx.insert(idpOrg).values({ idpId: idpRes.idpId, orgId: orgId, roleMapping: roleMapping || null, orgMapping: `'${orgId}'` }); }); const redirectUrl = await generateOidcRedirectUrl( idpId as number, orgId ); return response(res, { data: { idpId: idpId as number, redirectUrl }, success: true, error: false, message: "Org Idp created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/orgIdp/deleteOrgIdp.ts ================================================ /* * 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 } 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 { idp, idpOidcConfig, idpOrg } from "@server/db"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import privateConfig from "#private/lib/config"; const paramsSchema = z .object({ orgId: z.string().optional(), // Optional; used with org idp in saas idpId: z.coerce.number() }) .strict(); registry.registerPath({ method: "delete", path: "/org/{orgId}/idp/{idpId}", description: "Delete IDP for a specific organization.", tags: [OpenAPITags.OrgIdp], request: { params: paramsSchema }, responses: {} }); export async function deleteOrgIdp( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { idpId } = parsedParams.data; if ( privateConfig.getRawPrivateConfig().app.identity_provider_mode !== "org" ) { return next( createHttpError( HttpCode.BAD_REQUEST, "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." ) ); } // Check if IDP exists const [existingIdp] = await db .select() .from(idp) .where(eq(idp.idpId, idpId)); if (!existingIdp) { return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found")); } // Delete the IDP and its related records in a transaction await db.transaction(async (trx) => { // Delete OIDC config if it exists await trx .delete(idpOidcConfig) .where(eq(idpOidcConfig.idpId, idpId)); // Delete IDP-org mappings await trx.delete(idpOrg).where(eq(idpOrg.idpId, idpId)); // Delete the IDP itself await trx.delete(idp).where(eq(idp.idpId, idpId)); }); return response(res, { data: null, success: true, error: false, message: "IdP deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/orgIdp/getOrgIdp.ts ================================================ /* * 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, idpOrg, loginPage, loginPageOrg } from "@server/db"; import { idp, idpOidcConfig } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { GetOrgIdpResponse } from "@server/routers/orgIdp/types"; const paramsSchema = z .object({ orgId: z.string().nonempty(), idpId: z.coerce.number() }) .strict(); async function query(idpId: number, orgId: string) { const [res] = await db .select() .from(idp) .where(eq(idp.idpId, idpId)) .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .leftJoin( idpOrg, and(eq(idpOrg.idpId, idp.idpId), eq(idpOrg.orgId, orgId)) ) .limit(1); return res; } registry.registerPath({ method: "get", path: "/org/{orgId}/idp/{idpId}", description: "Get an IDP by its IDP ID for a specific organization.", tags: [OpenAPITags.OrgIdp], request: { params: paramsSchema }, responses: {} }); export async function getOrgIdp( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { idpId, orgId } = parsedParams.data; const idpRes = await query(idpId, orgId); if (!idpRes) { return next(createHttpError(HttpCode.NOT_FOUND, "Idp not found")); } const key = config.getRawConfig().server.secret!; if (idpRes.idp.type === "oidc") { const clientSecret = idpRes.idpOidcConfig!.clientSecret; const clientId = idpRes.idpOidcConfig!.clientId; idpRes.idpOidcConfig!.clientSecret = decrypt(clientSecret, key); idpRes.idpOidcConfig!.clientId = decrypt(clientId, key); } const redirectUrl = await generateOidcRedirectUrl( idpRes.idp.idpId, orgId ); return response(res, { data: { ...idpRes, redirectUrl }, success: true, error: false, message: "Org Idp retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/orgIdp/index.ts ================================================ /* * 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 "./createOrgOidcIdp"; export * from "./getOrgIdp"; export * from "./listOrgIdps"; export * from "./updateOrgOidcIdp"; export * from "./deleteOrgIdp"; ================================================ FILE: server/private/routers/orgIdp/listOrgIdps.ts ================================================ /* * 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, idpOidcConfig } from "@server/db"; import { idp, idpOrg } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { ListOrgIdpsResponse } from "@server/routers/orgIdp/types"; const querySchema = z.strictObject({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().nonnegative()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); async function query(orgId: string, limit: number, offset: number) { const res = await db .select({ idpId: idp.idpId, orgId: idpOrg.orgId, name: idp.name, type: idp.type, variant: idpOidcConfig.variant, tags: idp.tags }) .from(idpOrg) .where(eq(idpOrg.orgId, orgId)) .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idpOrg.idpId)) .orderBy(sql`idp.name DESC`) .limit(limit) .offset(offset); return res; } registry.registerPath({ method: "get", path: "/org/{orgId}/idp", description: "List all IDP for a specific organization.", tags: [OpenAPITags.OrgIdp], request: { query: querySchema, params: paramsSchema }, responses: {} }); export async function listOrgIdps( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { limit, offset } = parsedQuery.data; const list = await query(orgId, limit, offset); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(idp); return response(res, { data: { idps: list, pagination: { total: count, limit, offset } }, success: true, error: false, message: "Org Idps retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/orgIdp/updateOrgOidcIdp.ts ================================================ /* * 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, idpOrg } 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 { OpenAPITags, registry } from "@server/openApi"; import { idp, idpOidcConfig } from "@server/db"; import { eq, and } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; import { isSubscribed } from "#private/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import privateConfig from "#private/lib/config"; import { build } from "@server/build"; const paramsSchema = z .object({ orgId: z.string().nonempty(), idpId: z.coerce.number() }) .strict(); const bodySchema = z.strictObject({ name: z.string().optional(), clientId: z.string().optional(), clientSecret: z.string().optional(), authUrl: z.string().optional(), tokenUrl: z.string().optional(), identifierPath: z.string().optional(), emailPath: z.string().optional(), namePath: z.string().optional(), scopes: z.string().optional(), autoProvision: z.boolean().optional(), roleMapping: z.string().optional(), tags: z.string().optional() }); export type UpdateOrgIdpResponse = { idpId: number; }; registry.registerPath({ method: "post", path: "/org/{orgId}/idp/{idpId}/oidc", description: "Update an OIDC IdP for a specific organization.", tags: [OpenAPITags.OrgIdp], request: { params: paramsSchema, body: { content: { "application/json": { schema: bodySchema } } } }, responses: {} }); export async function updateOrgOidcIdp( req: Request, res: Response, next: NextFunction ): Promise { 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() ) ); } if ( privateConfig.getRawPrivateConfig().app.identity_provider_mode !== "org" ) { return next( createHttpError( HttpCode.BAD_REQUEST, "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." ) ); } const { idpId, orgId } = parsedParams.data; const { clientId, clientSecret, authUrl, tokenUrl, scopes, identifierPath, emailPath, namePath, name, roleMapping, tags } = parsedBody.data; let { autoProvision } = parsedBody.data; if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted const subscribed = await isSubscribed( orgId, tierMatrix.deviceApprovals ); if (!subscribed) { autoProvision = false; } } // Check if IDP exists and is of type OIDC const [existingIdp] = await db .select() .from(idp) .where(eq(idp.idpId, idpId)); if (!existingIdp) { return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found")); } const [existingIdpOrg] = await db .select() .from(idpOrg) .where(and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId))); if (!existingIdpOrg) { return next( createHttpError( HttpCode.NOT_FOUND, "IdP not found for this organization" ) ); } if (existingIdp.type !== "oidc") { return next( createHttpError( HttpCode.BAD_REQUEST, "IdP is not an OIDC provider" ) ); } const key = config.getRawConfig().server.secret!; const encryptedSecret = clientSecret ? encrypt(clientSecret, key) : undefined; const encryptedClientId = clientId ? encrypt(clientId, key) : undefined; await db.transaction(async (trx) => { const idpData = { name, autoProvision, tags }; // only update if at least one key is not undefined let keysToUpdate = Object.keys(idpData).filter( (key) => idpData[key as keyof typeof idpData] !== undefined ); if (keysToUpdate.length > 0) { await trx.update(idp).set(idpData).where(eq(idp.idpId, idpId)); } const configData = { clientId: encryptedClientId, clientSecret: encryptedSecret, authUrl, tokenUrl, scopes, identifierPath, emailPath, namePath }; keysToUpdate = Object.keys(configData).filter( (key) => configData[key as keyof typeof configData] !== undefined ); if (keysToUpdate.length > 0) { // Update OIDC config await trx .update(idpOidcConfig) .set(configData) .where(eq(idpOidcConfig.idpId, idpId)); } if (roleMapping !== undefined) { // Update IdP-org policy await trx .update(idpOrg) .set({ roleMapping }) .where( and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)) ); } }); return response(res, { data: { idpId }, success: true, error: false, message: "Org IdP updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/re-key/index.ts ================================================ /* * 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 "./reGenerateClientSecret"; export * from "./reGenerateSiteSecret"; export * from "./reGenerateExitNodeSecret"; ================================================ FILE: server/private/routers/re-key/reGenerateClientSecret.ts ================================================ /* * 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, Olm, olms } from "@server/db"; import { clients } 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 { eq, and } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { hashPassword } from "@server/auth/password"; import { disconnectClient, sendToClient } from "#private/routers/ws"; import { OlmErrorCodes, sendOlmError } from "@server/routers/olm/error"; import { sendTerminateClient } from "@server/routers/client/terminate"; const reGenerateSecretParamsSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) }); const reGenerateSecretBodySchema = z.strictObject({ // olmId: z.string().min(1).optional(), secret: z.string().min(1), disconnect: z.boolean().optional().default(true) }); export type ReGenerateSecretBody = z.infer; export async function reGenerateClientSecret( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = reGenerateSecretBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { secret, disconnect } = parsedBody.data; const parsedParams = reGenerateSecretParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { clientId } = parsedParams.data; const secretHash = await hashPassword(secret); // Fetch the client to make sure it exists and the user has access to it const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { return next( createHttpError( HttpCode.NOT_FOUND, `Client with ID ${clientId} not found` ) ); } const existingOlms = await db .select() .from(olms) .where(eq(olms.clientId, clientId)); if (existingOlms.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `No OLM found for client ID ${clientId}` ) ); } if (existingOlms.length > 1) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Multiple OLM entries found for client ID ${clientId}` ) ); } await db .update(olms) .set({ secretHash }) .where(eq(olms.olmId, existingOlms[0].olmId)); // Only disconnect if explicitly requested if (disconnect) { // Don't await this to prevent blocking the response sendTerminateClient( clientId, OlmErrorCodes.TERMINATED_REKEYED, existingOlms[0].olmId ).catch((error) => { logger.error( "Failed to send termination message to olm:", error ); }); disconnectClient(existingOlms[0].olmId).catch((error) => { logger.error("Failed to disconnect olm after re-key:", error); }); } return response(res, { data: { olmId: existingOlms[0].olmId }, success: true, error: false, message: "Credentials regenerated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/re-key/reGenerateExitNodeSecret.ts ================================================ /* * 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 { NextFunction, Request, Response } from "express"; import { db, exitNodes, exitNodeOrgs, ExitNode, ExitNodeOrg, RemoteExitNode } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import { z } from "zod"; import { remoteExitNodes } from "@server/db"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { fromError } from "zod-validation-error"; import { hashPassword } from "@server/auth/password"; import logger from "@server/logger"; import { and, eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { disconnectClient, sendToClient } from "#private/routers/ws"; export const paramsSchema = z.object({ orgId: z.string() }); const bodySchema = z.strictObject({ remoteExitNodeId: z.string().length(15), secret: z.string().length(48), disconnect: z.boolean().optional().default(true) }); export async function reGenerateExitNodeSecret( req: Request, res: Response, next: NextFunction ): Promise { 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 { remoteExitNodeId, secret, disconnect } = parsedBody.data; const [existingRemoteExitNode] = await db .select() .from(remoteExitNodes) .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); if (!existingRemoteExitNode) { return next( createHttpError( HttpCode.NOT_FOUND, "Remote Exit Node does not exist" ) ); } const secretHash = await hashPassword(secret); await db .update(remoteExitNodes) .set({ secretHash }) .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); // Only disconnect if explicitly requested if (disconnect) { const payload = { type: `remoteExitNode/terminate`, data: {} }; // Don't await this to prevent blocking the response sendToClient( existingRemoteExitNode.remoteExitNodeId, payload ).catch((error) => { logger.error( "Failed to send termination message to remote exit node:", error ); }); disconnectClient(existingRemoteExitNode.remoteExitNodeId).catch( (error) => { logger.error( "Failed to disconnect remote exit node after re-key:", error ); } ); } return response(res, { data: null, success: true, error: false, message: "Remote Exit Node secret updated successfully", status: HttpCode.OK }); } catch (e) { logger.error("Failed to update remoteExitNode", e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to update remoteExitNode" ) ); } } ================================================ FILE: server/private/routers/re-key/reGenerateSiteSecret.ts ================================================ /* * 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, Newt, newts, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { hashPassword } from "@server/auth/password"; import { addPeer, deletePeer } from "@server/routers/gerbil/peers"; import { getAllowedIps } from "@server/routers/target/helpers"; import { disconnectClient, sendToClient } from "#private/routers/ws"; const updateSiteParamsSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) }); const updateSiteBodySchema = z.strictObject({ type: z.enum(["newt", "wireguard"]), secret: z.string().min(1).max(255).optional(), pubKey: z.string().optional(), disconnect: z.boolean().optional().default(true) }); export async function reGenerateSiteSecret( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = updateSiteParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = updateSiteBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { siteId } = parsedParams.data; const { type, pubKey, secret, disconnect } = parsedBody.data; let existingNewt: Newt | null = null; if (type === "newt") { if (!secret) { return next( createHttpError( HttpCode.BAD_REQUEST, "newtSecret is required for newt sites" ) ); } const secretHash = await hashPassword(secret); // get the newt to verify it exists const existingNewts = await db .select() .from(newts) .where(eq(newts.siteId, siteId)); if (existingNewts.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `No Newt found for site ID ${siteId}` ) ); } if (existingNewts.length > 1) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Multiple Newts found for site ID ${siteId}` ) ); } existingNewt = existingNewts[0]; // update the secret on the existing newt await db .update(newts) .set({ secretHash }) .where(eq(newts.newtId, existingNewts[0].newtId)); // Only disconnect if explicitly requested if (disconnect) { const payload = { type: `newt/wg/terminate`, data: {} }; // Don't await this to prevent blocking the response sendToClient(existingNewts[0].newtId, payload).catch( (error) => { logger.error( "Failed to send termination message to newt:", error ); } ); disconnectClient(existingNewts[0].newtId).catch((error) => { logger.error( "Failed to disconnect newt after re-key:", error ); }); } logger.info(`Regenerated Newt credentials for site ${siteId}`); } else if (type === "wireguard") { if (!pubKey) { return next( createHttpError( HttpCode.BAD_REQUEST, "Public key is required for wireguard sites" ) ); } try { const [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { return next( createHttpError( HttpCode.NOT_FOUND, `Site with ID ${siteId} not found` ) ); } await db .update(sites) .set({ pubKey }) .where(eq(sites.siteId, siteId)); if (!site) { return next( createHttpError( HttpCode.NOT_FOUND, `Site with ID ${siteId} not found` ) ); } if (site.exitNodeId && site.subnet) { await deletePeer(site.exitNodeId, site.pubKey!); // the old pubkey await addPeer(site.exitNodeId, { publicKey: pubKey, allowedIps: await getAllowedIps(site.siteId) }); } logger.info( `Regenerated WireGuard credentials for site ${siteId}` ); } catch (err) { logger.error( `Transaction failed while regenerating WireGuard secret for site ${siteId}`, err ); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to regenerate WireGuard credentials. Rolled back transaction." ) ); } } return response(res, { data: { newtId: existingNewt ? existingNewt.newtId : undefined }, success: true, error: false, message: "Credentials regenerated successfully", status: HttpCode.OK }); } catch (error) { logger.error("Unexpected error in reGenerateSiteSecret", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An unexpected error occurred" ) ); } } ================================================ FILE: server/private/routers/remoteExitNode/createRemoteExitNode.ts ================================================ /* * 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 { NextFunction, Request, Response } from "express"; 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"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { SqliteError } from "better-sqlite3"; import moment from "moment"; import { generateSessionToken } from "@server/auth/sessions/app"; import { createRemoteExitNodeSession } from "#private/auth/sessions/remoteExitNode"; import { fromError } from "zod-validation-error"; import { hashPassword, verifyPassword } from "@server/auth/password"; import logger from "@server/logger"; 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"; import { CreateRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; export const paramsSchema = z.object({ orgId: z.string() }); const bodySchema = z.strictObject({ remoteExitNodeId: z.string().length(15), secret: z.string().length(48) }); export type CreateRemoteExitNodeBody = z.infer; export async function createRemoteExitNode( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { remoteExitNodeId, secret } = parsedBody.data; if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); } const usage = await usageService.getUsage( orgId, FeatureId.REMOTE_EXIT_NODES ); if (usage) { const rejectRemoteExitNodes = await usageService.checkLimitSet( orgId, FeatureId.REMOTE_EXIT_NODES, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 } // We need to add one to know if we are violating the limit ); if (rejectRemoteExitNodes) { return next( createHttpError( HttpCode.FORBIDDEN, "Remote node limit exceeded. Please upgrade your plan." ) ); } } const secretHash = await hashPassword(secret); // const address = await getNextAvailableSubnet(); const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES const [existingRemoteExitNode] = await db .select() .from(remoteExitNodes) .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); if (existingRemoteExitNode) { // validate the secret const validSecret = await verifyPassword( secret, existingRemoteExitNode.secretHash ); if (!validSecret) { logger.info( `Failed secret validation for remote exit node: ${remoteExitNodeId}` ); return next( createHttpError( HttpCode.UNAUTHORIZED, "Invalid secret for remote exit node" ) ); } } let existingExitNode: ExitNode | null = null; if (existingRemoteExitNode?.exitNodeId) { const [res] = await db .select() .from(exitNodes) .where( eq(exitNodes.exitNodeId, existingRemoteExitNode.exitNodeId) ); existingExitNode = res; } let existingExitNodeOrg: ExitNodeOrg | null = null; if (existingRemoteExitNode?.exitNodeId) { const [res] = await db .select() .from(exitNodeOrgs) .where( and( eq( exitNodeOrgs.exitNodeId, existingRemoteExitNode.exitNodeId ), eq(exitNodeOrgs.orgId, orgId) ) ); existingExitNodeOrg = res; } if (existingExitNodeOrg) { return next( createHttpError( HttpCode.BAD_REQUEST, "Remote exit node already exists in this organization" ) ); } 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) { const [res] = await trx .insert(exitNodes) .values({ name: remoteExitNodeId, address, endpoint: "", publicKey: "", listenPort: 0, online: false, type: "remoteExitNode" }) .returning(); existingExitNode = res; } if (!existingRemoteExitNode) { await trx.insert(remoteExitNodes).values({ remoteExitNodeId: remoteExitNodeId, secretHash, dateCreated: moment().toISOString(), exitNodeId: existingExitNode.exitNodeId }); } else { // update the existing remote exit node await trx .update(remoteExitNodes) .set({ exitNodeId: existingExitNode.exitNodeId }) .where( eq( remoteExitNodes.remoteExitNodeId, existingRemoteExitNode.remoteExitNodeId ) ); } if (!existingExitNodeOrg) { await trx.insert(exitNodeOrgs).values({ exitNodeId: existingExitNode.exitNodeId, 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) ) ); 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); return response(res, { data: { remoteExitNodeId, secret, token }, success: true, error: false, message: "RemoteExitNode created successfully", status: HttpCode.OK }); } catch (e) { if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { return next( createHttpError( HttpCode.BAD_REQUEST, "A remote exit node with that ID already exists" ) ); } else { logger.error("Failed to create remoteExitNode", e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create remoteExitNode" ) ); } } } ================================================ FILE: server/private/routers/remoteExitNode/deleteRemoteExitNode.ts ================================================ /* * 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 { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { db, ExitNodeOrg, exitNodeOrgs, exitNodes, orgs } from "@server/db"; import { remoteExitNodes } from "@server/db"; 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"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; const paramsSchema = z.strictObject({ orgId: z.string().min(1), remoteExitNodeId: z.string().min(1) }); export async function deleteRemoteExitNode( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId, remoteExitNodeId } = parsedParams.data; const [remoteExitNode] = await db .select() .from(remoteExitNodes) .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) .limit(1); if (!remoteExitNode) { return next( createHttpError( HttpCode.NOT_FOUND, `Remote exit node with ID ${remoteExitNodeId} not found` ) ); } if (!remoteExitNode.exitNodeId) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, `Remote exit node with ID ${remoteExitNodeId} does not have an exit node ID` ) ); } 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) .where( and( eq(exitNodeOrgs.orgId, orgId), 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)); const billingOrgIds = otherBillingOrgs.map((o) => o.orgId); const orgsInBillingDomainThatTheNodeIsStillIn = await trx .select() .from(exitNodeOrgs) .where( and( eq( exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId! ), inArray(exitNodeOrgs.orgId, billingOrgIds) ) ); if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) { await usageService.add( orgId, FeatureId.REMOTE_EXIT_NODES, -1, trx ); } } }); return response(res, { data: null, success: true, error: false, message: "Remote exit node deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/remoteExitNode/getRemoteExitNode.ts ================================================ /* * 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 { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { db, exitNodes } from "@server/db"; import { remoteExitNodes } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { GetRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; const getRemoteExitNodeSchema = z.strictObject({ orgId: z.string().min(1), remoteExitNodeId: z.string().min(1) }); async function query(remoteExitNodeId: string) { const [remoteExitNode] = await db .select({ remoteExitNodeId: remoteExitNodes.remoteExitNodeId, dateCreated: remoteExitNodes.dateCreated, version: remoteExitNodes.version, exitNodeId: remoteExitNodes.exitNodeId, name: exitNodes.name, address: exitNodes.address, endpoint: exitNodes.endpoint, online: exitNodes.online, type: exitNodes.type }) .from(remoteExitNodes) .innerJoin( exitNodes, eq(exitNodes.exitNodeId, remoteExitNodes.exitNodeId) ) .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) .limit(1); return remoteExitNode; } export async function getRemoteExitNode( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getRemoteExitNodeSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { remoteExitNodeId } = parsedParams.data; const remoteExitNode = await query(remoteExitNodeId); if (!remoteExitNode) { return next( createHttpError( HttpCode.NOT_FOUND, `Remote exit node with ID ${remoteExitNodeId} not found` ) ); } return response(res, { data: remoteExitNode, success: true, error: false, message: "Remote exit node retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts ================================================ /* * 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 { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; import { remoteExitNodes } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } 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 { createRemoteExitNodeSession, validateRemoteExitNodeSessionToken } from "#private/auth/sessions/remoteExitNode"; import { verifyPassword } from "@server/auth/password"; import logger from "@server/logger"; import config from "@server/lib/config"; export const remoteExitNodeGetTokenBodySchema = z.object({ remoteExitNodeId: z.string(), secret: z.string(), token: z.string().optional() }); export async function getRemoteExitNodeToken( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = remoteExitNodeGetTokenBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { remoteExitNodeId, secret, token } = parsedBody.data; try { if (token) { const { session, remoteExitNode } = await validateRemoteExitNodeSessionToken(token); if (session) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `RemoteExitNode session already valid. RemoteExitNode ID: ${remoteExitNodeId}. IP: ${req.ip}.` ); } return response(res, { data: null, success: true, error: false, message: "Token session already valid", status: HttpCode.OK }); } } const existingRemoteExitNodeRes = await db .select() .from(remoteExitNodes) .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); if (!existingRemoteExitNodeRes || !existingRemoteExitNodeRes.length) { return next( createHttpError( HttpCode.BAD_REQUEST, "No remoteExitNode found with that remoteExitNodeId" ) ); } const existingRemoteExitNode = existingRemoteExitNodeRes[0]; const validSecret = await verifyPassword( secret, existingRemoteExitNode.secretHash ); if (!validSecret) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `RemoteExitNode id or secret is incorrect. RemoteExitNode: ID ${remoteExitNodeId}. IP: ${req.ip}.` ); } return next( createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect") ); } const resToken = generateSessionToken(); await createRemoteExitNodeSession( resToken, existingRemoteExitNode.remoteExitNodeId ); // logger.debug(`Created RemoteExitNode token response: ${JSON.stringify(resToken)}`); return response<{ token: string }>(res, { data: { token: resToken }, success: true, error: false, message: "Token created successfully", status: HttpCode.OK }); } catch (e) { console.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to authenticate remoteExitNode" ) ); } } ================================================ FILE: server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts ================================================ /* * 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 { db, exitNodes, sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, RemoteExitNode } from "@server/db"; import { eq, lt, isNull, and, or, inArray } from "drizzle-orm"; import logger from "@server/logger"; // Track if the offline checker interval is running let offlineCheckerInterval: NodeJS.Timeout | null = null; const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes /** * Starts the background interval that checks for clients that haven't pinged recently * and marks them as offline */ export const startRemoteExitNodeOfflineChecker = (): void => { if (offlineCheckerInterval) { return; // Already running } offlineCheckerInterval = setInterval(async () => { try { const twoMinutesAgo = Math.floor( (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 ); // Find clients that haven't pinged in the last 2 minutes and mark them as offline const newlyOfflineNodes = await db .update(exitNodes) .set({ online: false }) .where( and( eq(exitNodes.online, true), eq(exitNodes.type, "remoteExitNode"), or( lt(exitNodes.lastPing, twoMinutesAgo), isNull(exitNodes.lastPing) ) ) ) .returning(); // Update the sites to offline if they have not pinged either const exitNodeIds = newlyOfflineNodes.map( (node) => node.exitNodeId ); const sitesOnNode = await db .select() .from(sites) .where( and( eq(sites.online, true), inArray(sites.exitNodeId, exitNodeIds) ) ); // loop through the sites and process their lastBandwidthUpdate as an iso string and if its more than 1 minute old then mark the site offline for (const site of sitesOnNode) { if (!site.lastBandwidthUpdate) { continue; } const lastBandwidthUpdate = new Date(site.lastBandwidthUpdate); if (Date.now() - lastBandwidthUpdate.getTime() > 60 * 1000) { await db .update(sites) .set({ online: false }) .where(eq(sites.siteId, site.siteId)); } } } catch (error) { logger.error("Error in offline checker interval", { error }); } }, OFFLINE_CHECK_INTERVAL); logger.debug("Started offline checker interval"); }; /** * Stops the background interval that checks for offline clients */ export const stopRemoteExitNodeOfflineChecker = (): void => { if (offlineCheckerInterval) { clearInterval(offlineCheckerInterval); offlineCheckerInterval = null; logger.info("Stopped offline checker interval"); } }; /** * Handles ping messages from clients and responds with pong */ export const handleRemoteExitNodePingMessage: MessageHandler = async ( context ) => { const { message, client: c, sendToClient } = context; const remoteExitNode = c as RemoteExitNode; if (!remoteExitNode) { logger.debug("RemoteExitNode not found"); return; } if (!remoteExitNode.exitNodeId) { logger.debug("RemoteExitNode has no exit node ID!"); // this can happen if the exit node is created but not adopted yet return; } try { // Update the exit node's last ping timestamp await db .update(exitNodes) .set({ lastPing: Math.floor(Date.now() / 1000), online: true }) .where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId)); } catch (error) { logger.error("Error handling ping message", { error }); } return { message: { type: "pong", data: { timestamp: new Date().toISOString() } }, broadcast: false, excludeSender: false }; }; ================================================ FILE: server/private/routers/remoteExitNode/handleRemoteExitNodeRegisterMessage.ts ================================================ /* * 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 { db, RemoteExitNode, remoteExitNodes } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; export const handleRemoteExitNodeRegisterMessage: MessageHandler = async ( context ) => { const { message, client, sendToClient } = context; const remoteExitNode = client as RemoteExitNode; logger.debug("Handling register remoteExitNode message!"); if (!remoteExitNode) { logger.warn("Remote exit node not found"); return; } const { remoteExitNodeVersion, remoteExitNodeSecondaryVersion } = message.data; if (!remoteExitNodeVersion) { logger.warn("Remote exit node version not found"); return; } // update the version await db .update(remoteExitNodes) .set({ version: remoteExitNodeVersion, secondaryVersion: remoteExitNodeSecondaryVersion }) .where( eq( remoteExitNodes.remoteExitNodeId, remoteExitNode.remoteExitNodeId ) ); }; ================================================ FILE: server/private/routers/remoteExitNode/index.ts ================================================ /* * 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 "./createRemoteExitNode"; export * from "./getRemoteExitNode"; export * from "./listRemoteExitNodes"; export * from "./getRemoteExitNodeToken"; export * from "./handleRemoteExitNodeRegisterMessage"; export * from "./handleRemoteExitNodePingMessage"; export * from "./deleteRemoteExitNode"; export * from "./listRemoteExitNodes"; export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; ================================================ FILE: server/private/routers/remoteExitNode/listRemoteExitNodes.ts ================================================ /* * 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 { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { db, exitNodeOrgs, exitNodes } from "@server/db"; import { remoteExitNodes } from "@server/db"; import { eq, and, count } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; const listRemoteExitNodesParamsSchema = z.strictObject({ orgId: z.string() }); const listRemoteExitNodesSchema = z.object({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); export function queryRemoteExitNodes(orgId: string) { return db .select({ remoteExitNodeId: remoteExitNodes.remoteExitNodeId, dateCreated: remoteExitNodes.dateCreated, version: remoteExitNodes.version, exitNodeId: remoteExitNodes.exitNodeId, name: exitNodes.name, address: exitNodes.address, endpoint: exitNodes.endpoint, online: exitNodes.online, type: exitNodes.type }) .from(exitNodeOrgs) .where(eq(exitNodeOrgs.orgId, orgId)) .innerJoin(exitNodes, eq(exitNodes.exitNodeId, exitNodeOrgs.exitNodeId)) .innerJoin( remoteExitNodes, eq(remoteExitNodes.exitNodeId, exitNodeOrgs.exitNodeId) ); } export async function listRemoteExitNodes( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listRemoteExitNodesSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = listRemoteExitNodesParamsSchema.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" ) ); } const baseQuery = queryRemoteExitNodes(orgId); const countQuery = db .select({ count: count() }) .from(remoteExitNodes) .innerJoin( exitNodes, eq(exitNodes.exitNodeId, remoteExitNodes.exitNodeId) ) .where(eq(exitNodes.type, "remoteExitNode")); const remoteExitNodesList = await baseQuery.limit(limit).offset(offset); const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; return response(res, { data: { remoteExitNodes: remoteExitNodesList, pagination: { total: totalCount, limit, offset } }, success: true, error: false, message: "Remote exit nodes retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/remoteExitNode/pickRemoteExitNodeDefaults.ts ================================================ /* * 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 response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { generateId } from "@server/auth/sessions/app"; import { fromError } from "zod-validation-error"; import { z } from "zod"; import { PickRemoteExitNodeDefaultsResponse } from "@server/routers/remoteExitNode/types"; const paramsSchema = z.strictObject({ orgId: z.string() }); export async function pickRemoteExitNodeDefaults( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const remoteExitNodeId = generateId(15); const secret = generateId(48); return response(res, { data: { remoteExitNodeId, secret }, success: true, error: false, message: "Organization retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/private/routers/remoteExitNode/quickStartRemoteExitNode.ts ================================================ /* * 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 { NextFunction, Request, Response } from "express"; import { db } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import { remoteExitNodes } from "@server/db"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { SqliteError } from "better-sqlite3"; import moment from "moment"; import { generateId } from "@server/auth/sessions/app"; import { hashPassword } from "@server/auth/password"; import logger from "@server/logger"; import z from "zod"; import { fromError } from "zod-validation-error"; import { QuickStartRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; const INSTALLER_KEY = "af4e4785-7e09-11f0-b93a-74563c4e2a7e"; const quickStartRemoteExitNodeBodySchema = z.object({ token: z.string() }); export async function quickStartRemoteExitNode( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = quickStartRemoteExitNodeBodySchema.safeParse( req.body ); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { token } = parsedBody.data; const tokenValidation = validateTokenOnApi(token); if (!tokenValidation.isValid) { logger.info(`Failed token validation: ${tokenValidation.message}`); return next( createHttpError( HttpCode.UNAUTHORIZED, fromError(tokenValidation.message).toString() ) ); } const remoteExitNodeId = generateId(15); const secret = generateId(48); const secretHash = await hashPassword(secret); await db.insert(remoteExitNodes).values({ remoteExitNodeId, secretHash, dateCreated: moment().toISOString() }); return response(res, { data: { remoteExitNodeId, secret }, success: true, error: false, message: "Remote exit node created successfully", status: HttpCode.OK }); } catch (e) { if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { return next( createHttpError( HttpCode.BAD_REQUEST, "A remote exit node with that ID already exists" ) ); } else { logger.error("Failed to create remoteExitNode", e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create remoteExitNode" ) ); } } } /** * Validates a token received from the frontend. * @param {string} token The validation token from the request. * @returns {{ isValid: boolean; message: string }} An object indicating if the token is valid. */ const validateTokenOnApi = ( token: string ): { isValid: boolean; message: string } => { if (!token) { return { isValid: false, message: "Error: No token provided." }; } try { // 1. Decode the base64 string const decodedB64 = atob(token); // 2. Reverse the character code manipulation const deobfuscated = decodedB64 .split("") .map((char) => String.fromCharCode(char.charCodeAt(0) - 5)) // Reverse the shift .join(""); // 3. Split the data to get the original secret and timestamp const parts = deobfuscated.split("|"); if (parts.length !== 2) { throw new Error("Invalid token format."); } const receivedKey = parts[0]; const tokenTimestamp = parseInt(parts[1], 10); // 4. Check if the secret key matches if (receivedKey !== INSTALLER_KEY) { logger.info(`Token key mismatch. Received: ${receivedKey}`); return { isValid: false, message: "Invalid token: Key mismatch." }; } // 5. Check if the timestamp is recent (e.g., within 30 seconds) to prevent replay attacks const now = Date.now(); const timeDifference = now - tokenTimestamp; if (timeDifference > 30000) { // 30 seconds return { isValid: false, message: "Invalid token: Expired." }; } if (timeDifference < 0) { // Timestamp is in the future return { isValid: false, message: "Invalid token: Timestamp is in the future." }; } // If all checks pass, the token is valid return { isValid: true, message: "Token is valid!" }; } catch (error) { // This will catch errors from atob (if not valid base64) or other issues. return { isValid: false, message: `Error: ${(error as Error).message}` }; } }; ================================================ FILE: server/private/routers/resource/getMaintenanceInfo.ts ================================================ /* * 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 } from "@server/db"; import { resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import { GetMaintenanceInfoResponse } from "@server/routers/resource/types"; const getMaintenanceInfoSchema = z .object({ fullDomain: z.string().min(1, "Domain is required") }) .strict(); async function query(fullDomain: string) { const [res] = await db .select({ resourceId: resources.resourceId, name: resources.name, fullDomain: resources.fullDomain, maintenanceModeEnabled: resources.maintenanceModeEnabled, maintenanceModeType: resources.maintenanceModeType, maintenanceTitle: resources.maintenanceTitle, maintenanceMessage: resources.maintenanceMessage, maintenanceEstimatedTime: resources.maintenanceEstimatedTime }) .from(resources) .where(eq(resources.fullDomain, fullDomain)) .limit(1); return res; } registry.registerPath({ method: "get", path: "/maintenance/info", description: "Get maintenance information for a resource by domain.", tags: [OpenAPITags.PublicResource], request: { query: z.object({ fullDomain: z.string() }) }, responses: { 200: { description: "Maintenance information retrieved successfully" }, 404: { description: "Resource not found" } } }); export async function getMaintenanceInfo( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = getMaintenanceInfoSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { fullDomain } = parsedQuery.data; const maintenanceInfo = await query(fullDomain); if (!maintenanceInfo) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } return response(res, { data: maintenanceInfo, success: true, error: false, message: "Maintenance information retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred while retrieving maintenance information" ) ); } } ================================================ FILE: server/private/routers/resource/index.ts ================================================ /* * 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 "./getMaintenanceInfo"; ================================================ FILE: server/private/routers/ssh/index.ts ================================================ /* * 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"; ================================================ FILE: server/private/routers/ssh/signSshKey.ts ================================================ /* * 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 { actionAuditLog, db, logsDb, 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 { eq, or, and } from "drizzle-orm"; import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource"; import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA"; import config from "@server/lib/config"; import { sendToClient } from "#private/routers/ws"; import { ActionsEnum } from "@server/auth/actions"; 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; siteId: 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 { 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" ) ); } // prefix with p- usernameToUse = `p-${usernameToUse}`; // 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" ) ); } } await db .update(userOrgs) .set({ pamUsername: usernameToUse }) .where( and( eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId) ) ); } 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" ) ); } if (resource.mode == "cidr") { return next( createHttpError( HttpCode.BAD_REQUEST, "SSHing is not supported for CIDR resources" ) ); } // Check if the user has access to the resource const hasAccess = await canUserAccessSiteResource({ userId: userId, 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; } await logsDb.insert(actionAuditLog).values({ timestamp: Math.floor(Date.now() / 1000), orgId: orgId, actorType: "user", actor: req.user?.username ?? "", actorId: req.user?.userId ?? "", action: ActionsEnum.signSshKey, metadata: JSON.stringify({ resourceId: resource.siteResourceId, resource: resource.name, siteId: resource.siteId, }) }); return response(res, { data: { certificate: cert.certificate, messageId: message.messageId, sshUsername: usernameToUse, sshHost: sshHost, resourceId: resource.siteResourceId, siteId: resource.siteId, 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" ) ); } } ================================================ FILE: server/private/routers/ws/index.ts ================================================ /* * 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 "./ws"; ================================================ FILE: server/private/routers/ws/messageHandlers.ts ================================================ /* * 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 { handleRemoteExitNodeRegisterMessage, handleRemoteExitNodePingMessage, startRemoteExitNodeOfflineChecker } from "#private/routers/remoteExitNode"; import { MessageHandler } from "@server/routers/ws"; import { build } from "@server/build"; export const messageHandlers: Record = { "remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/ping": handleRemoteExitNodePingMessage }; if (build != "saas") { startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes } ================================================ FILE: server/private/routers/ws/ws.ts ================================================ /* * 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 { Router, Request, Response } from "express"; import zlib from "zlib"; import { Server as HttpServer } from "http"; import { WebSocket, WebSocketServer } from "ws"; import { Socket } from "net"; import { Newt, newts, NewtSession, olms, Olm, OlmSession, RemoteExitNode, RemoteExitNodeSession, remoteExitNodes, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { db } from "@server/db"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import logger from "@server/logger"; import redisManager from "#private/lib/redis"; import { v4 as uuidv4 } from "uuid"; import { validateRemoteExitNodeSessionToken } from "#private/auth/sessions/remoteExitNode"; import { rateLimitService } from "#private/lib/rateLimit"; import { messageHandlers } from "@server/routers/ws/messageHandlers"; import { messageHandlers as privateMessageHandlers } from "#private/routers/ws/messageHandlers"; import { AuthenticatedWebSocket, ClientType, WSMessage, TokenPayload, WebSocketRequest, RedisMessage, SendMessageOptions } from "@server/routers/ws"; import { validateSessionToken } from "@server/auth/sessions/app"; // Merge public and private message handlers Object.assign(messageHandlers, privateMessageHandlers); const MAX_PENDING_MESSAGES = 50; // Maximum messages to queue during connection setup // Helper function to process a single message const processMessage = async ( ws: AuthenticatedWebSocket, data: Buffer, isBinary: boolean, clientId: string, clientType: ClientType ): Promise => { try { const messageBuffer = isBinary ? zlib.gunzipSync(data) : data; const message: WSMessage = JSON.parse(messageBuffer.toString()); // logger.debug( // `Processing message from ${clientType.toUpperCase()} ID: ${clientId}, type: ${message.type}` // ); if (!message.type || typeof message.type !== "string") { throw new Error("Invalid message format: missing or invalid type"); } // Check rate limiting with message type awareness const rateLimitResult = await rateLimitService.checkRateLimit( clientId, message.type, // Pass message type for granular limiting 100, // max requests per window 100, // max requests per message type per window 60 * 1000 // window in milliseconds ); if (rateLimitResult.isLimited) { const reason = rateLimitResult.reason === "global" ? "too many messages" : `too many '${message.type}' messages`; logger.debug( `Rate limit exceeded for ${clientType.toUpperCase()} ID: ${clientId} - ${reason}, ignoring message` ); // Send rate limit error to client // ws.send(JSON.stringify({ // type: "rate_limit_error", // data: { // message: `Rate limit exceeded: ${reason}`, // messageType: message.type, // reason: rateLimitResult.reason // } // })); return; } const handler = messageHandlers[message.type]; if (!handler) { throw new Error(`Unsupported message type: ${message.type}`); } const response = await handler({ message, senderWs: ws, client: ws.client, clientType: ws.clientType!, sendToClient, broadcastToAllExcept, connectedClients }); if (response) { if (response.broadcast) { await broadcastToAllExcept( response.message, response.excludeSender ? clientId : undefined, response.options ); } else if (response.targetClientId) { await sendToClient( response.targetClientId, response.message, response.options ); } else { await sendToClient( clientId, response.message, response.options ); } } } catch (error) { logger.error("Message handling error:", error); // ws.send(JSON.stringify({ // type: "error", // data: { // message: error instanceof Error ? error.message : "Unknown error occurred", // originalMessage: data.toString() // } // })); } }; // Helper function to process pending messages const processPendingMessages = async ( ws: AuthenticatedWebSocket, clientId: string, clientType: ClientType ): Promise => { if (!ws.pendingMessages || ws.pendingMessages.length === 0) { return; } logger.info( `Processing ${ws.pendingMessages.length} pending messages for ${clientType.toUpperCase()} ID: ${clientId}` ); const jobs = []; for (const pending of ws.pendingMessages) { jobs.push( processMessage( ws, pending.data, pending.isBinary, clientId, clientType ) ); } await Promise.all(jobs); ws.pendingMessages = []; // Clear pending messages to prevent reprocessing }; const router: Router = Router(); const wss: WebSocketServer = new WebSocketServer({ noServer: true }); // Generate unique node ID for this instance const NODE_ID = uuidv4(); const REDIS_CHANNEL = "websocket_messages"; // Client tracking map (local to this node) const connectedClients: Map = new Map(); // Config version tracking map (local to this node, resets on server restart) const clientConfigVersions: Map = new Map(); // Tracks the last Unix timestamp (seconds) at which a ping was flushed to the // DB for a given siteId. Resets on server restart which is fine – the first // ping after startup will always write, re-establishing the online state. const lastPingDbWrite: Map = new Map(); const PING_DB_WRITE_INTERVAL = 45; // seconds // Recovery tracking let isRedisRecoveryInProgress = false; // Helper to get map key const getClientMapKey = (clientId: string) => clientId; // Redis keys (generalized) const getConnectionsKey = (clientId: string) => `ws:connections:${clientId}`; const getNodeConnectionsKey = (nodeId: string, clientId: string) => `ws:node:${nodeId}:${clientId}`; const getConfigVersionKey = (clientId: string) => `ws:configVersion:${clientId}`; // Initialize Redis subscription for cross-node messaging const initializeRedisSubscription = async (): Promise => { if (!redisManager.isRedisEnabled()) return; await redisManager.subscribe( REDIS_CHANNEL, async (channel: string, message: string) => { try { const redisMessage: RedisMessage = JSON.parse(message); // Ignore messages from this node if (redisMessage.fromNodeId === NODE_ID) return; if ( redisMessage.type === "direct" && redisMessage.targetClientId ) { // Send to specific client on this node await sendToClientLocal( redisMessage.targetClientId, redisMessage.message ); } else if (redisMessage.type === "broadcast") { // Broadcast to all clients on this node except excluded await broadcastToAllExceptLocal( redisMessage.message, redisMessage.excludeClientId ); } } catch (error) { logger.error("Error processing Redis message:", error); } } ); }; // Simple self-healing recovery function // Each node is responsible for restoring its own connection state to Redis // This approach is more efficient than cross-node coordination because: // 1. Each node knows its own connections (source of truth) // 2. No network overhead from broadcasting state between nodes // 3. No race conditions from simultaneous updates // 4. Redis becomes eventually consistent as each node restores independently // 5. Simpler logic with better fault tolerance const recoverConnectionState = async (): Promise => { if (isRedisRecoveryInProgress) { logger.debug("Redis recovery already in progress, skipping"); return; } isRedisRecoveryInProgress = true; logger.info("Starting Redis connection state recovery..."); try { // Each node simply restores its own local connections to Redis // This is the source of truth - no need for cross-node coordination await restoreLocalConnectionsToRedis(); logger.info( "Redis connection state recovery completed - restored local state" ); } catch (error) { logger.error("Error during Redis recovery:", error); } finally { isRedisRecoveryInProgress = false; } }; const restoreLocalConnectionsToRedis = async (): Promise => { if (!redisManager.isRedisEnabled()) return; logger.info("Restoring local connections to Redis..."); let restoredCount = 0; try { // Restore all current local connections to Redis for (const [clientId, clients] of connectedClients.entries()) { const validClients = clients.filter( (client) => client.readyState === WebSocket.OPEN ); if (validClients.length > 0) { // Add this node to the client's connection list await redisManager.sadd(getConnectionsKey(clientId), NODE_ID); // Store individual connection details for (const client of validClients) { if (client.connectionId) { await redisManager.hset( getNodeConnectionsKey(NODE_ID, clientId), client.connectionId, Date.now().toString() ); } } restoredCount++; } } logger.info(`Restored ${restoredCount} client connections to Redis`); } catch (error) { logger.error("Failed to restore local connections to Redis:", error); } }; // Helper functions for client management const addClient = async ( clientType: ClientType, clientId: string, ws: AuthenticatedWebSocket ): Promise => { // Generate unique connection ID const connectionId = uuidv4(); ws.connectionId = connectionId; // Add to local tracking const mapKey = getClientMapKey(clientId); const existingClients = connectedClients.get(mapKey) || []; existingClients.push(ws); connectedClients.set(mapKey, existingClients); // Get or initialize config version let configVersion = 0; // Check Redis first if enabled if (redisManager.isRedisEnabled()) { try { const redisVersion = await redisManager.get( getConfigVersionKey(clientId) ); if (redisVersion !== null) { configVersion = parseInt(redisVersion, 10); // Sync to local cache clientConfigVersions.set(clientId, configVersion); } else if (!clientConfigVersions.has(clientId)) { // No version in Redis or local cache, initialize to 0 await redisManager.set(getConfigVersionKey(clientId), "0"); clientConfigVersions.set(clientId, 0); } else { // Use local cache version and sync to Redis configVersion = clientConfigVersions.get(clientId) || 0; await redisManager.set( getConfigVersionKey(clientId), configVersion.toString() ); } } catch (error) { logger.error("Failed to get/set config version in Redis:", error); // Fall back to local cache if (!clientConfigVersions.has(clientId)) { clientConfigVersions.set(clientId, 0); } configVersion = clientConfigVersions.get(clientId) || 0; } } else { // Redis not enabled, use local cache only if (!clientConfigVersions.has(clientId)) { clientConfigVersions.set(clientId, 0); } configVersion = clientConfigVersions.get(clientId) || 0; } // Set config version on websocket ws.configVersion = configVersion; // Add to Redis tracking if enabled if (redisManager.isRedisEnabled()) { try { await redisManager.sadd(getConnectionsKey(clientId), NODE_ID); await redisManager.hset( getNodeConnectionsKey(NODE_ID, clientId), connectionId, Date.now().toString() ); } catch (error) { logger.error( "Failed to add client to Redis tracking (connection still functional locally):", error ); } } logger.info( `Client added to tracking - ${clientType.toUpperCase()} ID: ${clientId}, Connection ID: ${connectionId}, Total connections: ${existingClients.length}, Config version: ${configVersion}` ); }; const removeClient = async ( clientType: ClientType, clientId: string, ws: AuthenticatedWebSocket ): Promise => { const mapKey = getClientMapKey(clientId); const existingClients = connectedClients.get(mapKey) || []; const updatedClients = existingClients.filter((client) => client !== ws); if (updatedClients.length === 0) { connectedClients.delete(mapKey); if (redisManager.isRedisEnabled()) { try { await redisManager.srem(getConnectionsKey(clientId), NODE_ID); await redisManager.del( getNodeConnectionsKey(NODE_ID, clientId) ); } catch (error) { logger.error( "Failed to remove client from Redis tracking (cleanup will occur on recovery):", error ); } } logger.info( `All connections removed for ${clientType.toUpperCase()} ID: ${clientId}` ); } else { connectedClients.set(mapKey, updatedClients); if (redisManager.isRedisEnabled() && ws.connectionId) { try { await redisManager.hdel( getNodeConnectionsKey(NODE_ID, clientId), ws.connectionId ); } catch (error) { logger.error( "Failed to remove specific connection from Redis tracking:", error ); } } logger.info( `Connection removed - ${clientType.toUpperCase()} ID: ${clientId}, Remaining connections: ${updatedClients.length}` ); } }; // Helper to get the current config version for a client const getClientConfigVersion = async ( clientId: string ): Promise => { // Try Redis first if available if (redisManager.isRedisEnabled()) { try { const redisVersion = await redisManager.get( getConfigVersionKey(clientId) ); if (redisVersion !== null) { const version = parseInt(redisVersion, 10); // Sync local cache with Redis clientConfigVersions.set(clientId, version); return version; } } catch (error) { logger.error("Failed to get config version from Redis:", error); } } // Fall back to local cache return clientConfigVersions.get(clientId); }; // Helper to increment and get the new config version for a client const incrementClientConfigVersion = async ( clientId: string ): Promise => { let newVersion: number; if (redisManager.isRedisEnabled()) { try { // Use Redis INCR for atomic increment across nodes newVersion = await redisManager.incr(getConfigVersionKey(clientId)); // Sync local cache clientConfigVersions.set(clientId, newVersion); return newVersion; } catch (error) { logger.error("Failed to increment config version in Redis:", error); // Fall through to local increment } } // Local increment const currentVersion = clientConfigVersions.get(clientId) || 0; newVersion = currentVersion + 1; clientConfigVersions.set(clientId, newVersion); return newVersion; }; // Local message sending (within this node) const sendToClientLocal = async ( clientId: string, message: WSMessage, options: SendMessageOptions = {} ): Promise => { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); if (!clients || clients.length === 0) { return false; } // Handle config version const configVersion = await getClientConfigVersion(clientId); // Add config version to message const messageWithVersion = { ...message, configVersion }; const messageString = JSON.stringify(messageWithVersion); if (options.compress) { logger.debug( `Message size before compression: ${messageString.length} bytes` ); const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8")); logger.debug( `Message size after compression: ${compressed.length} bytes` ); clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(compressed); } }); } else { clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(messageString); } }); } return true; }; const broadcastToAllExceptLocal = async ( message: WSMessage, excludeClientId?: string, options: SendMessageOptions = {} ): Promise => { for (const [mapKey, clients] of connectedClients.entries()) { const [type, id] = mapKey.split(":"); const clientId = mapKey; // mapKey is the clientId if (!(excludeClientId && clientId === excludeClientId)) { // Handle config version per client let configVersion = await getClientConfigVersion(clientId); if (options.incrementConfigVersion) { configVersion = await incrementClientConfigVersion(clientId); } // Add config version to message const messageWithVersion = { ...message, configVersion }; if (options.compress) { const compressed = zlib.gzipSync( Buffer.from(JSON.stringify(messageWithVersion), "utf8") ); clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(compressed); } }); } else { clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify(messageWithVersion)); } }); } } } }; // Cross-node message sending (via Redis) const sendToClient = async ( clientId: string, message: WSMessage, options: SendMessageOptions = {} ): Promise => { let configVersion = await getClientConfigVersion(clientId); if (options.incrementConfigVersion) { configVersion = await incrementClientConfigVersion(clientId); } logger.debug( `sendToClient: Message type ${message.type} sent to clientId ${clientId} (new configVersion: ${configVersion})` ); // Try to send locally first const localSent = await sendToClientLocal(clientId, message, options); // Only send via Redis if the client is not connected locally and Redis is enabled if (!localSent && redisManager.isRedisEnabled()) { try { const redisMessage: RedisMessage = { type: "direct", targetClientId: clientId, message: { ...message, configVersion }, fromNodeId: NODE_ID }; await redisManager.publish( REDIS_CHANNEL, JSON.stringify(redisMessage) ); } catch (error) { logger.error( "Failed to send message via Redis, message may be lost:", error ); // Continue execution - local delivery already attempted } } else if (!localSent && !redisManager.isRedisEnabled()) { // Redis is disabled or unavailable - log that we couldn't deliver to remote nodes logger.debug( `Could not deliver message to ${clientId} - not connected locally and Redis unavailable` ); } return localSent; }; const broadcastToAllExcept = async ( message: WSMessage, excludeClientId?: string, options: SendMessageOptions = {} ): Promise => { // Broadcast locally await broadcastToAllExceptLocal(message, excludeClientId, options); // If Redis is enabled, also broadcast via Redis pub/sub to other nodes // Note: For broadcasts, we include the options so remote nodes can handle versioning if (redisManager.isRedisEnabled()) { try { const redisMessage: RedisMessage = { type: "broadcast", excludeClientId, message, fromNodeId: NODE_ID, options }; await redisManager.publish( REDIS_CHANNEL, JSON.stringify(redisMessage) ); } catch (error) { logger.error( "Failed to broadcast message via Redis, remote nodes may not receive it:", error ); // Continue execution - local broadcast already completed } } else { logger.debug( "Redis unavailable - broadcast limited to local node only" ); } }; // Check if a client has active connections across all nodes const hasActiveConnections = async (clientId: string): Promise => { if (!redisManager.isRedisEnabled()) { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); return !!(clients && clients.length > 0); } const activeNodes = await redisManager.smembers( getConnectionsKey(clientId) ); return activeNodes.length > 0; }; // Get all active nodes for a client const getActiveNodes = async ( clientType: ClientType, clientId: string ): Promise => { if (!redisManager.isRedisEnabled()) { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); return clients && clients.length > 0 ? [NODE_ID] : []; } return await redisManager.smembers(getConnectionsKey(clientId)); }; // Token verification middleware const verifyToken = async ( token: string, clientType: ClientType, userToken: string ): Promise => { try { if (clientType === "newt") { const { session, newt } = await validateNewtSessionToken(token); if (!session || !newt) { return null; } const existingNewt = await db .select() .from(newts) .where(eq(newts.newtId, newt.newtId)); if (!existingNewt || !existingNewt[0]) { return null; } return { client: existingNewt[0], session, clientType }; } else if (clientType === "olm") { const { session, olm } = await validateOlmSessionToken(token); if (!session || !olm) { return null; } const existingOlm = await db .select() .from(olms) .where(eq(olms.olmId, olm.olmId)); if (!existingOlm || !existingOlm[0]) { return null; } if (olm.userId) { // this is a user device and we need to check the user token const { session: userSession, user } = await validateSessionToken(userToken); if (!userSession || !user) { return null; } if (user.userId !== olm.userId) { return null; } } return { client: existingOlm[0], session, clientType }; } else if (clientType === "remoteExitNode") { const { session, remoteExitNode } = await validateRemoteExitNodeSessionToken(token); if (!session || !remoteExitNode) { return null; } const existingRemoteExitNode = await db .select() .from(remoteExitNodes) .where( eq( remoteExitNodes.remoteExitNodeId, remoteExitNode.remoteExitNodeId ) ); if (!existingRemoteExitNode || !existingRemoteExitNode[0]) { return null; } return { client: existingRemoteExitNode[0], session, clientType }; } return null; } catch (error) { logger.error("Token verification failed:", error); return null; } }; const setupConnection = async ( ws: AuthenticatedWebSocket, client: Newt | Olm | RemoteExitNode, clientType: ClientType ): Promise => { logger.info("Establishing websocket connection"); if (!client) { logger.error("Connection attempt without client"); return ws.terminate(); } ws.client = client; ws.clientType = clientType; ws.isFullyConnected = false; ws.pendingMessages = []; // Get client ID first let clientId: string; if (clientType === "newt") { clientId = (client as Newt).newtId; } else if (clientType === "olm") { clientId = (client as Olm).olmId; } else if (clientType === "remoteExitNode") { clientId = (client as RemoteExitNode).remoteExitNodeId; } else { throw new Error(`Unknown client type: ${clientType}`); } // Set up message handler FIRST to prevent race condition ws.on("message", async (data, isBinary) => { if (!ws.isFullyConnected) { // Queue message for later processing with limits ws.pendingMessages = ws.pendingMessages || []; if (ws.pendingMessages.length >= MAX_PENDING_MESSAGES) { logger.warn( `Too many pending messages for ${clientType.toUpperCase()} ID: ${clientId}, dropping oldest message` ); ws.pendingMessages.shift(); // Remove oldest message } logger.debug( `Queueing message from ${clientType.toUpperCase()} ID: ${clientId} (connection not fully established)` ); ws.pendingMessages.push({ data: data as Buffer, isBinary }); return; } await processMessage( ws, data as Buffer, isBinary, clientId, clientType ); }); // Set up other event handlers before async operations ws.on("close", async () => { // Clear any pending messages to prevent memory leaks if (ws.pendingMessages) { ws.pendingMessages = []; } await removeClient(clientType, clientId, ws); logger.info( `Client disconnected - ${clientType.toUpperCase()} ID: ${clientId}` ); }); // Handle WebSocket protocol-level pings from older newt clients that do // not send application-level "newt/ping" messages. Update the site's // online state and lastPing timestamp so the offline checker treats them // the same as modern newt clients. if (clientType === "newt") { const newtClient = client as Newt; ws.on("ping", async () => { if (!newtClient.siteId) return; const now = Math.floor(Date.now() / 1000); const lastWrite = lastPingDbWrite.get(newtClient.siteId) ?? 0; if (now - lastWrite < PING_DB_WRITE_INTERVAL) return; lastPingDbWrite.set(newtClient.siteId, now); try { await db .update(sites) .set({ online: true, lastPing: now }) .where(eq(sites.siteId, newtClient.siteId)); } catch (error) { logger.error( "Error updating newt site online state on WS ping", { error } ); } }); } ws.on("error", (error: Error) => { logger.error( `WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`, error ); }); try { await addClient(clientType, clientId, ws); // Mark connection as fully established ws.isFullyConnected = true; logger.info( `WebSocket connection fully established and ready - ${clientType.toUpperCase()} ID: ${clientId}` ); // Process any messages that were queued while connection was being established await processPendingMessages(ws, clientId, clientType); } catch (error) { logger.error( `Failed to fully establish connection for ${clientType.toUpperCase()} ID: ${clientId}:`, error ); // ws.send(JSON.stringify({ // type: "connection_error", // data: { // message: "Failed to establish connection" // } // })); ws.terminate(); return; } }; // Router endpoint router.get("/ws", (req: Request, res: Response) => { res.status(200).send("WebSocket endpoint"); }); // WebSocket upgrade handler const handleWSUpgrade = (server: HttpServer): void => { server.on( "upgrade", async (request: WebSocketRequest, socket: Socket, head: Buffer) => { try { const url = new URL( request.url || "", `http://${request.headers.host}` ); const token = url.searchParams.get("token") || request.headers["sec-websocket-protocol"] || ""; const userToken = url.searchParams.get("userToken") || ""; let clientType = url.searchParams.get( "clientType" ) as ClientType; if (!clientType) { clientType = "newt"; } if ( !token || !clientType || !["newt", "olm", "remoteExitNode"].includes(clientType) ) { logger.warn( "Unauthorized connection attempt: invalid token or client type..." ); socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); return; } const tokenPayload = await verifyToken( token, clientType, userToken ); if (!tokenPayload) { logger.debug( "Unauthorized connection attempt: invalid token..." ); socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); return; } wss.handleUpgrade( request, socket, head, (ws: AuthenticatedWebSocket) => { setupConnection( ws, tokenPayload.client, tokenPayload.clientType ); } ); } catch (error) { logger.error("WebSocket upgrade error:", error); socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n"); socket.destroy(); } } ); }; // Add periodic connection state sync to handle Redis disconnections/reconnections const startPeriodicStateSync = (): void => { // Lightweight sync every 5 minutes - just restore our own state setInterval( async () => { if (redisManager.isRedisEnabled() && !isRedisRecoveryInProgress) { try { await restoreLocalConnectionsToRedis(); logger.debug("Periodic connection state sync completed"); } catch (error) { logger.error( "Error during periodic connection state sync:", error ); } } }, 5 * 60 * 1000 ); // 5 minutes // Cleanup stale connections every 15 minutes setInterval( async () => { if (redisManager.isRedisEnabled()) { try { await cleanupStaleConnections(); logger.debug("Periodic connection cleanup completed"); } catch (error) { logger.error( "Error during periodic connection cleanup:", error ); } } }, 15 * 60 * 1000 ); // 15 minutes }; const cleanupStaleConnections = async (): Promise => { if (!redisManager.isRedisEnabled()) return; try { const nodeKeys = (await redisManager.getClient()?.keys(`ws:node:${NODE_ID}:*`)) || []; for (const nodeKey of nodeKeys) { const connections = await redisManager.hgetall(nodeKey); const clientId = nodeKey.replace(`ws:node:${NODE_ID}:`, ""); const localClients = connectedClients.get(clientId) || []; const localConnectionIds = localClients .filter((client) => client.readyState === WebSocket.OPEN) .map((client) => client.connectionId) .filter(Boolean); // Remove Redis entries for connections that no longer exist locally for (const [connectionId, timestamp] of Object.entries( connections )) { if (!localConnectionIds.includes(connectionId)) { await redisManager.hdel(nodeKey, connectionId); logger.debug( `Cleaned up stale connection: ${connectionId} for client: ${clientId}` ); } } // If no connections remain for this client, remove from Redis entirely const remainingConnections = await redisManager.hgetall(nodeKey); if (Object.keys(remainingConnections).length === 0) { await redisManager.srem(getConnectionsKey(clientId), NODE_ID); await redisManager.del(nodeKey); logger.debug( `Cleaned up empty connection tracking for client: ${clientId}` ); } } } catch (error) { logger.error("Error cleaning up stale connections:", error); } }; // Initialize Redis subscription when the module is loaded if (redisManager.isRedisEnabled()) { initializeRedisSubscription().catch((error) => { logger.error("Failed to initialize Redis subscription:", error); }); // Register recovery callback with Redis manager // When Redis reconnects, each node simply restores its own local state redisManager.onReconnection(async () => { logger.info("Redis reconnected, starting WebSocket state recovery..."); await recoverConnectionState(); }); // Start periodic state synchronization startPeriodicStateSync(); logger.info( `WebSocket handler initialized with Redis support - Node ID: ${NODE_ID}` ); } else { logger.debug("WebSocket handler initialized in local mode"); } // Disconnect a specific client and force them to reconnect const disconnectClient = async (clientId: string): Promise => { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); if (!clients || clients.length === 0) { logger.debug(`No connections found for client ID: ${clientId}`); return false; } logger.info( `Disconnecting client ID: ${clientId} (${clients.length} connection(s))` ); // Close all connections for this client clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.close(1000, "Disconnected by server"); } }); return true; }; // Cleanup function for graceful shutdown const cleanup = async (): Promise => { try { // Close all WebSocket connections connectedClients.forEach((clients) => { clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.terminate(); } }); }); // Clean up Redis tracking for this node if (redisManager.isRedisEnabled()) { const keys = (await redisManager .getClient() ?.keys(`ws:node:${NODE_ID}:*`)) || []; if (keys.length > 0) { await Promise.all(keys.map((key) => redisManager.del(key))); } } logger.info("WebSocket cleanup completed"); } catch (error) { logger.error("Error during WebSocket cleanup:", error); } }; export { router, handleWSUpgrade, sendToClient, broadcastToAllExcept, connectedClients, hasActiveConnections, getActiveNodes, disconnectClient, NODE_ID, cleanup, getClientConfigVersion }; ================================================ FILE: server/routers/accessToken/deleteAccessToken.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; 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 { resourceAccessToken } from "@server/db"; import { and, eq } from "drizzle-orm"; import { db } from "@server/db"; import { OpenAPITags, registry } from "@server/openApi"; const deleteAccessTokenParamsSchema = z.strictObject({ accessTokenId: z.string() }); registry.registerPath({ method: "delete", path: "/access-token/{accessTokenId}", description: "Delete a access token.", tags: [OpenAPITags.AccessToken], request: { params: deleteAccessTokenParamsSchema }, responses: {} }); export async function deleteAccessToken( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = deleteAccessTokenParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { accessTokenId } = parsedParams.data; const [accessToken] = await db .select() .from(resourceAccessToken) .where(and(eq(resourceAccessToken.accessTokenId, accessTokenId))); if (!accessToken) { return next( createHttpError( HttpCode.NOT_FOUND, "Resource access token not found" ) ); } await db .delete(resourceAccessToken) .where(and(eq(resourceAccessToken.accessTokenId, accessTokenId))); return response(res, { data: null, success: true, error: false, message: "Resource access token deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/accessToken/generateAccessToken.ts ================================================ import { hash } from "@node-rs/argon2"; import { generateId, generateIdFromEntropySize, SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app"; import { db } from "@server/db"; import { ResourceAccessToken, resourceAccessToken, resources } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } 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 logger from "@server/logger"; import { createDate, TimeSpan } from "oslo"; import { hashPassword } from "@server/auth/password"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { OpenAPITags, registry } from "@server/openApi"; export const generateAccessTokenBodySchema = z.strictObject({ validForSeconds: z.int().positive().optional(), // seconds title: z.string().optional(), description: z.string().optional() }); export const generateAccssTokenParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); export type GenerateAccessTokenResponse = Omit< ResourceAccessToken, "tokenHash" > & { accessToken: string }; registry.registerPath({ method: "post", path: "/resource/{resourceId}/access-token", description: "Generate a new access token for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken], request: { params: generateAccssTokenParamsSchema, body: { content: { "application/json": { schema: generateAccessTokenBodySchema } } } }, responses: {} }); export async function generateAccessToken( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = generateAccessTokenBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const parsedParams = generateAccssTokenParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const { validForSeconds, title, description } = parsedBody.data; const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)); if (!resource) { return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found")); } try { const sessionLength = validForSeconds ? validForSeconds * 1000 : SESSION_COOKIE_EXPIRES; const expiresAt = validForSeconds ? createDate(new TimeSpan(validForSeconds, "s")).getTime() : undefined; const token = generateIdFromEntropySize(16); const tokenHash = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); const id = generateId(8); const [result] = await db .insert(resourceAccessToken) .values({ accessTokenId: id, orgId: resource.orgId, resourceId, tokenHash, expiresAt: expiresAt || null, sessionLength: sessionLength, title: title || null, description: description || null, createdAt: new Date().getTime() }) .returning({ accessTokenId: resourceAccessToken.accessTokenId, orgId: resourceAccessToken.orgId, resourceId: resourceAccessToken.resourceId, expiresAt: resourceAccessToken.expiresAt, sessionLength: resourceAccessToken.sessionLength, title: resourceAccessToken.title, description: resourceAccessToken.description, createdAt: resourceAccessToken.createdAt }) .execute(); if (!result) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to generate access token" ) ); } return response(res, { data: { ...result, accessToken: token }, success: true, error: false, message: "Resource access token generated successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to authenticate with resource" ) ); } } ================================================ FILE: server/routers/accessToken/index.ts ================================================ export * from "./generateAccessToken"; export * from "./listAccessTokens"; export * from "./deleteAccessToken"; ================================================ FILE: server/routers/accessToken/listAccessTokens.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resources, userResources, roleResources, resourceAccessToken, sites } 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, isNull, lt, gt } from "drizzle-orm"; import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const listAccessTokensParamsSchema = z .strictObject({ resourceId: z .string() .optional() .transform(stoi) .pipe(z.int().positive().optional()), orgId: z.string().optional() }) .refine((data) => !!data.resourceId !== !!data.orgId, { error: "Either resourceId or orgId must be provided, but not both" }); const listAccessTokensSchema = z.object({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().nonnegative()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); function queryAccessTokens( accessibleResourceIds: number[], orgId?: string, resourceId?: number ) { const cols = { accessTokenId: resourceAccessToken.accessTokenId, orgId: resourceAccessToken.orgId, resourceId: resourceAccessToken.resourceId, sessionLength: resourceAccessToken.sessionLength, expiresAt: resourceAccessToken.expiresAt, tokenHash: resourceAccessToken.tokenHash, title: resourceAccessToken.title, description: resourceAccessToken.description, createdAt: resourceAccessToken.createdAt, resourceName: resources.name, resourceNiceId: resources.niceId, siteName: sites.name }; if (orgId) { return db .select(cols) .from(resourceAccessToken) .leftJoin( resources, eq(resourceAccessToken.resourceId, resources.resourceId) ) .leftJoin(sites, eq(resources.resourceId, sites.siteId)) .where( and( inArray( resourceAccessToken.resourceId, accessibleResourceIds ), eq(resourceAccessToken.orgId, orgId), or( isNull(resourceAccessToken.expiresAt), gt(resourceAccessToken.expiresAt, new Date().getTime()) ) ) ); } else if (resourceId) { return db .select(cols) .from(resourceAccessToken) .leftJoin( resources, eq(resourceAccessToken.resourceId, resources.resourceId) ) .leftJoin(sites, eq(resources.resourceId, sites.siteId)) .where( and( inArray( resourceAccessToken.resourceId, accessibleResourceIds ), eq(resourceAccessToken.resourceId, resourceId), or( isNull(resourceAccessToken.expiresAt), gt(resourceAccessToken.expiresAt, new Date().getTime()) ) ) ); } } export type ListAccessTokensResponse = { accessTokens: NonNullable>>; pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ method: "get", path: "/org/{orgId}/access-tokens", description: "List all access tokens in an organization.", tags: [OpenAPITags.AccessToken], request: { params: z.object({ orgId: z.string() }), query: listAccessTokensSchema }, responses: {} }); registry.registerPath({ method: "get", path: "/resource/{resourceId}/access-tokens", description: "List all access tokens for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken], request: { params: z.object({ resourceId: z.number() }), query: listAccessTokensSchema }, responses: {} }); export async function listAccessTokens( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listAccessTokensSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedQuery.error) ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = listAccessTokensParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const { resourceId } = parsedParams.data; const orgId = parsedParams.data.orgId || req.userOrg?.orgId || req.apiKeyOrg?.orgId; if (!orgId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") ); } if (req.user && orgId && orgId !== req.userOrgId) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } let accessibleResources; if (req.user) { accessibleResources = await db .select({ resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})` }) .from(userResources) .fullJoin( roleResources, eq(userResources.resourceId, roleResources.resourceId) ) .where( or( eq(userResources.userId, req.user!.userId), eq(roleResources.roleId, req.userOrgRoleId!) ) ); } else { accessibleResources = await db .select({ resourceId: resources.resourceId }) .from(resources) .where(eq(resources.orgId, orgId)); } const accessibleResourceIds = accessibleResources.map( (resource) => resource.resourceId ); const countQuery: any = db .select({ count: count() }) .from(resources) .where(inArray(resources.resourceId, accessibleResourceIds)); const baseQuery = queryAccessTokens( accessibleResourceIds, orgId, resourceId ); const list = await baseQuery!.limit(limit).offset(offset); const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; return response(res, { data: { accessTokens: list, pagination: { total: totalCount, limit, offset } }, success: true, error: false, message: "Access tokens retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/apiKeys/createOrgApiKey.ts ================================================ import { NextFunction, Request, Response } from "express"; import { db } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import { z } from "zod"; import { apiKeyOrg, apiKeys } from "@server/db"; import { fromError } from "zod-validation-error"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import moment from "moment"; import { generateId, generateIdFromEntropySize } from "@server/auth/sessions/app"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ orgId: z.string().nonempty() }); const bodySchema = z.object({ name: z.string().min(1).max(255) }); export type CreateOrgApiKeyBody = z.infer; export type CreateOrgApiKeyResponse = { apiKeyId: string; name: string; apiKey: string; lastChars: string; createdAt: string; }; registry.registerPath({ method: "put", path: "/org/{orgId}/api-key", description: "Create a new API key scoped to the organization.", tags: [OpenAPITags.ApiKey], request: { params: paramsSchema, body: { content: { "application/json": { schema: bodySchema } } } }, responses: {} }); export async function createOrgApiKey( req: Request, res: Response, next: NextFunction ): Promise { 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 { name } = parsedBody.data; const apiKeyId = generateId(15); const apiKey = generateIdFromEntropySize(25); const apiKeyHash = await hashPassword(apiKey); const lastChars = apiKey.slice(-4); const createdAt = moment().toISOString(); await db.transaction(async (trx) => { await trx.insert(apiKeys).values({ name, apiKeyId, apiKeyHash, createdAt, lastChars }); await trx.insert(apiKeyOrg).values({ apiKeyId, orgId }); }); try { return response(res, { data: { apiKeyId, apiKey, name, lastChars, createdAt }, success: true, error: false, message: "API key created", status: HttpCode.CREATED }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create API key" ) ); } } ================================================ FILE: server/routers/apiKeys/createRootApiKey.ts ================================================ import { NextFunction, Request, Response } from "express"; import { db } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import { z } from "zod"; import { apiKeyOrg, apiKeys, orgs } from "@server/db"; import { fromError } from "zod-validation-error"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import moment from "moment"; import { generateId, generateIdFromEntropySize } from "@server/auth/sessions/app"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; const bodySchema = z.strictObject({ name: z.string().min(1).max(255) }); export type CreateRootApiKeyBody = z.infer; export type CreateRootApiKeyResponse = { apiKeyId: string; name: string; apiKey: string; lastChars: string; createdAt: string; }; export async function createRootApiKey( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { name } = parsedBody.data; const apiKeyId = generateId(15); const apiKey = generateIdFromEntropySize(25); const apiKeyHash = await hashPassword(apiKey); const lastChars = apiKey.slice(-4); const createdAt = moment().toISOString(); await db.transaction(async (trx) => { await trx.insert(apiKeys).values({ apiKeyId, name, apiKeyHash, createdAt, lastChars, isRoot: true }); }); try { return response(res, { data: { apiKeyId, name, apiKey, lastChars, createdAt }, success: true, error: false, message: "API key created", status: HttpCode.CREATED }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create API key" ) ); } } ================================================ FILE: server/routers/apiKeys/deleteApiKey.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { apiKeys } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ apiKeyId: z.string().nonempty() }); registry.registerPath({ method: "delete", path: "/org/{orgId}/api-key/{apiKeyId}", description: "Delete an API key.", tags: [OpenAPITags.ApiKey], request: { params: paramsSchema }, responses: {} }); export async function deleteApiKey( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { apiKeyId } = parsedParams.data; const [apiKey] = await db .select() .from(apiKeys) .where(eq(apiKeys.apiKeyId, apiKeyId)) .limit(1); if (!apiKey) { return next( createHttpError( HttpCode.NOT_FOUND, `API Key with ID ${apiKeyId} not found` ) ); } await db.delete(apiKeys).where(eq(apiKeys.apiKeyId, apiKeyId)); return response(res, { data: null, success: true, error: false, message: "API key deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/apiKeys/deleteOrgApiKey.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { apiKeyOrg, apiKeys } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const paramsSchema = z.object({ apiKeyId: z.string().nonempty(), orgId: z.string().nonempty() }); export async function deleteOrgApiKey( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { apiKeyId, orgId } = parsedParams.data; const [apiKey] = await db .select() .from(apiKeys) .where(eq(apiKeys.apiKeyId, apiKeyId)) .innerJoin( apiKeyOrg, and( eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId), eq(apiKeyOrg.orgId, orgId) ) ) .limit(1); if (!apiKey) { return next( createHttpError( HttpCode.NOT_FOUND, `API Key with ID ${apiKeyId} not found` ) ); } if (apiKey.apiKeys.isRoot) { return next( createHttpError( HttpCode.FORBIDDEN, "Cannot delete root API key" ) ); } await db.transaction(async (trx) => { await trx .delete(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId) ) ); const apiKeyOrgs = await db .select() .from(apiKeyOrg) .where(eq(apiKeyOrg.apiKeyId, apiKeyId)); if (apiKeyOrgs.length === 0) { await trx.delete(apiKeys).where(eq(apiKeys.apiKeyId, apiKeyId)); } }); return response(res, { data: null, success: true, error: false, message: "API removed from organization", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/apiKeys/getApiKey.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { apiKeys } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const paramsSchema = z.object({ apiKeyId: z.string().nonempty() }); async function query(apiKeyId: string) { return await db .select({ apiKeyId: apiKeys.apiKeyId, lastChars: apiKeys.lastChars, createdAt: apiKeys.createdAt, isRoot: apiKeys.isRoot, name: apiKeys.name }) .from(apiKeys) .where(eq(apiKeys.apiKeyId, apiKeyId)) .limit(1); } export type GetApiKeyResponse = NonNullable< Awaited>[0] >; export async function getApiKey( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { apiKeyId } = parsedParams.data; const [apiKey] = await query(apiKeyId); if (!apiKey) { return next( createHttpError( HttpCode.NOT_FOUND, `API Key with ID ${apiKeyId} not found` ) ); } return response(res, { data: apiKey, success: true, error: false, message: "API key deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/apiKeys/index.ts ================================================ export * from "./createRootApiKey"; export * from "./deleteApiKey"; export * from "./getApiKey"; export * from "./listApiKeyActions"; export * from "./listOrgApiKeys"; export * from "./listApiKeyActions"; export * from "./listRootApiKeys"; export * from "./setApiKeyActions"; export * from "./setApiKeyOrgs"; export * from "./createOrgApiKey"; export * from "./deleteOrgApiKey"; ================================================ FILE: server/routers/apiKeys/listApiKeyActions.ts ================================================ import { db } from "@server/db"; import { actions, apiKeyActions, apiKeyOrg, apiKeys } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ apiKeyId: z.string().nonempty() }); const querySchema = z.object({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); function queryActions(apiKeyId: string) { return db .select({ actionId: actions.actionId }) .from(apiKeyActions) .where(eq(apiKeyActions.apiKeyId, apiKeyId)) .innerJoin(actions, eq(actions.actionId, apiKeyActions.actionId)); } export type ListApiKeyActionsResponse = { actions: Awaited>; pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ method: "get", path: "/org/{orgId}/api-key/{apiKeyId}/actions", description: "List all actions set for an API key.", tags: [OpenAPITags.ApiKey], request: { params: paramsSchema, query: querySchema }, responses: {} }); export async function listApiKeyActions( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const { limit, offset } = parsedQuery.data; const { apiKeyId } = parsedParams.data; const baseQuery = queryActions(apiKeyId); const actionsList = await baseQuery.limit(limit).offset(offset); return response(res, { data: { actions: actionsList, pagination: { total: actionsList.length, limit, offset } }, success: true, error: false, message: "API keys retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/apiKeys/listOrgApiKeys.ts ================================================ import { db } from "@server/db"; import { apiKeyOrg, apiKeys } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const querySchema = z.object({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); const paramsSchema = z.object({ orgId: z.string() }); function queryApiKeys(orgId: string) { return db .select({ apiKeyId: apiKeys.apiKeyId, orgId: apiKeyOrg.orgId, lastChars: apiKeys.lastChars, createdAt: apiKeys.createdAt, name: apiKeys.name }) .from(apiKeyOrg) .where(and(eq(apiKeyOrg.orgId, orgId), eq(apiKeys.isRoot, false))) .innerJoin(apiKeys, eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId)); } export type ListOrgApiKeysResponse = { apiKeys: Awaited>; pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ method: "get", path: "/org/{orgId}/api-keys", description: "List all API keys for an organization", tags: [OpenAPITags.ApiKey], request: { params: paramsSchema, query: querySchema }, responses: {} }); export async function listOrgApiKeys( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const { limit, offset } = parsedQuery.data; const { orgId } = parsedParams.data; const baseQuery = queryApiKeys(orgId); const apiKeysList = await baseQuery.limit(limit).offset(offset); return response(res, { data: { apiKeys: apiKeysList, pagination: { total: apiKeysList.length, limit, offset } }, success: true, error: false, message: "API keys retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/apiKeys/listRootApiKeys.ts ================================================ import { db } from "@server/db"; import { apiKeys } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; const querySchema = z.object({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); function queryApiKeys() { return db .select({ apiKeyId: apiKeys.apiKeyId, lastChars: apiKeys.lastChars, createdAt: apiKeys.createdAt, name: apiKeys.name }) .from(apiKeys) .where(eq(apiKeys.isRoot, true)); } export type ListRootApiKeysResponse = { apiKeys: Awaited>; pagination: { total: number; limit: number; offset: number }; }; export async function listRootApiKeys( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const { limit, offset } = parsedQuery.data; const baseQuery = queryApiKeys(); const apiKeysList = await baseQuery.limit(limit).offset(offset); return response(res, { data: { apiKeys: apiKeysList, pagination: { total: apiKeysList.length, limit, offset } }, success: true, error: false, message: "API keys retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/apiKeys/setApiKeyActions.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { actions, apiKeyActions } 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, and, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const bodySchema = z.strictObject({ actionIds: z .tuple([z.string()], z.string()) .transform((v) => Array.from(new Set(v))) }); const paramsSchema = z.object({ apiKeyId: z.string().nonempty() }); registry.registerPath({ method: "post", path: "/org/{orgId}/api-key/{apiKeyId}/actions", description: "Set actions for an API key. This will replace any existing actions.", tags: [OpenAPITags.ApiKey], request: { params: paramsSchema, body: { content: { "application/json": { schema: bodySchema } } } }, responses: {} }); export async function setApiKeyActions( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { actionIds: newActionIds } = parsedBody.data; const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { apiKeyId } = parsedParams.data; const actionsExist = await db .select() .from(actions) .where(inArray(actions.actionId, newActionIds)); if (actionsExist.length !== newActionIds.length) { return next( createHttpError( HttpCode.BAD_REQUEST, "One or more actions do not exist" ) ); } await db.transaction(async (trx) => { const existingActions = await trx .select() .from(apiKeyActions) .where(eq(apiKeyActions.apiKeyId, apiKeyId)); const existingActionIds = existingActions.map((a) => a.actionId); const actionIdsToAdd = newActionIds.filter( (id) => !existingActionIds.includes(id) ); const actionIdsToRemove = existingActionIds.filter( (id) => !newActionIds.includes(id) ); if (actionIdsToRemove.length > 0) { await trx .delete(apiKeyActions) .where( and( eq(apiKeyActions.apiKeyId, apiKeyId), inArray(apiKeyActions.actionId, actionIdsToRemove) ) ); } if (actionIdsToAdd.length > 0) { const insertValues = actionIdsToAdd.map((actionId) => ({ apiKeyId, actionId })); await trx.insert(apiKeyActions).values(insertValues); } }); return response(res, { data: {}, success: true, error: false, message: "API key actions updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/apiKeys/setApiKeyOrgs.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { apiKeyOrg, orgs } 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, and, inArray } from "drizzle-orm"; const bodySchema = z.strictObject({ orgIds: z .tuple([z.string()], z.string()) .transform((v) => Array.from(new Set(v))) }); const paramsSchema = z.object({ apiKeyId: z.string().nonempty() }); export async function setApiKeyOrgs( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { orgIds: newOrgIds } = parsedBody.data; const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { apiKeyId } = parsedParams.data; // make sure all orgs exist const allOrgs = await db .select() .from(orgs) .where(inArray(orgs.orgId, newOrgIds)); if (allOrgs.length !== newOrgIds.length) { return next( createHttpError( HttpCode.BAD_REQUEST, "One or more orgs do not exist" ) ); } await db.transaction(async (trx) => { const existingOrgs = await trx .select({ orgId: apiKeyOrg.orgId }) .from(apiKeyOrg) .where(eq(apiKeyOrg.apiKeyId, apiKeyId)); const existingOrgIds = existingOrgs.map((a) => a.orgId); const orgIdsToAdd = newOrgIds.filter( (id) => !existingOrgIds.includes(id) ); const orgIdsToRemove = existingOrgIds.filter( (id) => !newOrgIds.includes(id) ); if (orgIdsToRemove.length > 0) { await trx .delete(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKeyId), inArray(apiKeyOrg.orgId, orgIdsToRemove) ) ); } if (orgIdsToAdd.length > 0) { const insertValues = orgIdsToAdd.map((orgId) => ({ apiKeyId, orgId })); await trx.insert(apiKeyOrg).values(insertValues); } return response(res, { data: {}, success: true, error: false, message: "API key orgs updated successfully", status: HttpCode.OK }); }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/auditLogs/exportRequestAuditLog.ts ================================================ import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; import { OpenAPITags } from "@server/openApi"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, queryRequest, countRequestQuery } from "./queryRequestAuditLog"; import { generateCSV } from "./generateCSV"; export const MAX_EXPORT_LIMIT = 50_000; registry.registerPath({ method: "get", path: "/org/{orgId}/logs/request", description: "Query the request audit log for an organization", tags: [OpenAPITags.Logs], request: { query: queryAccessAuditLogsQuery.omit({ limit: true, offset: true }), params: queryRequestAuditLogsParams }, responses: {} }); export async function exportRequestAuditLogs( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const data = { ...parsedQuery.data, ...parsedParams.data }; const [{ count }] = await countRequestQuery(data); if (count > MAX_EXPORT_LIMIT) { return next( createHttpError( HttpCode.BAD_REQUEST, `Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.` ) ); } const baseQuery = queryRequest(data); const log = await baseQuery.limit(MAX_EXPORT_LIMIT); const csvData = generateCSV(log); res.setHeader("Content-Type", "text/csv"); res.setHeader( "Content-Disposition", `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"` ); return res.send(csvData); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/auditLogs/generateCSV.ts ================================================ export function generateCSV(data: any[]): string { if (data.length === 0) { return "orgId,action,actorType,timestamp,actor\n"; } const headers = Object.keys(data[0]).join(","); const rows = data.map((row) => Object.values(row) .map((value) => typeof value === "string" && value.includes(",") ? `"${value.replace(/"/g, '""')}"` : value ) .join(",") ); return [headers, ...rows].join("\n"); } ================================================ FILE: server/routers/auditLogs/index.ts ================================================ export * from "./queryRequestAuditLog"; export * from "./queryRequestAnalytics"; export * from "./exportRequestAuditLog"; ================================================ FILE: server/routers/auditLogs/queryRequestAnalytics.ts ================================================ import { logsDb, requestAuditLog, driver, primaryLogsDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; import { eq, gte, lte, and, count, sql, desc, not, isNull } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import response from "@server/lib/response"; import logger from "@server/logger"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; const queryAccessAuditLogsQuery = z.object({ // iso string just validate its a parseable date timeStart: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() .prefault(() => getSevenDaysAgo().toISOString()) .openapi({ type: "string", format: "date-time", description: "Start time as ISO date string (defaults to 7 days ago)" }), timeEnd: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() .prefault(() => new Date().toISOString()) .openapi({ type: "string", format: "date-time", description: "End time as ISO date string (defaults to current time)" }), resourceId: z .string() .optional() .transform(Number) .pipe(z.int().positive()) .optional() }); const queryRequestAuditLogsParams = z.object({ orgId: z.string() }); const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge( queryRequestAuditLogsParams ); type Q = z.infer; async function query(query: Q) { let baseConditions = and( eq(requestAuditLog.orgId, query.orgId), gte(requestAuditLog.timestamp, query.timeStart), lte(requestAuditLog.timestamp, query.timeEnd) ); if (query.resourceId) { baseConditions = and( baseConditions, eq(requestAuditLog.resourceId, query.resourceId) ); } const [all] = await primaryLogsDb .select({ total: count() }) .from(requestAuditLog) .where(baseConditions); const [blocked] = await primaryLogsDb .select({ total: count() }) .from(requestAuditLog) .where(and(baseConditions, eq(requestAuditLog.action, false))); const totalQ = sql`count(${requestAuditLog.id})` .mapWith(Number) .as("total"); const DISTINCT_LIMIT = 500; const requestsPerCountry = await primaryLogsDb .selectDistinct({ code: requestAuditLog.location, count: totalQ }) .from(requestAuditLog) .where(and(baseConditions, not(isNull(requestAuditLog.location)))) .groupBy(requestAuditLog.location) .orderBy(desc(totalQ)) .limit(DISTINCT_LIMIT + 1); if (requestsPerCountry.length > DISTINCT_LIMIT) { // throw an error throw createHttpError( HttpCode.BAD_REQUEST, // todo: is this even possible? `Too many distinct countries. Please narrow your query.` ); } const groupByDayFunction = driver === "pg" ? sql`DATE_TRUNC('day', TO_TIMESTAMP(${requestAuditLog.timestamp}))` : sql`DATE(${requestAuditLog.timestamp}, 'unixepoch')`; const booleanTrue = driver === "pg" ? sql`true` : sql`1`; const booleanFalse = driver === "pg" ? sql`false` : sql`0`; const requestsPerDay = await primaryLogsDb .select({ day: groupByDayFunction.as("day"), allowedCount: sql`SUM(CASE WHEN ${requestAuditLog.action} = ${booleanTrue} THEN 1 ELSE 0 END)`.as( "allowed_count" ), blockedCount: sql`SUM(CASE WHEN ${requestAuditLog.action} = ${booleanFalse} THEN 1 ELSE 0 END)`.as( "blocked_count" ), totalCount: sql`COUNT(*)`.as("total_count") }) .from(requestAuditLog) .where(and(baseConditions)) .groupBy(groupByDayFunction) .orderBy(groupByDayFunction); return { requestsPerCountry: requestsPerCountry as Array<{ code: string; count: number; }>, requestsPerDay, totalBlocked: blocked.total, totalRequests: all.total }; } registry.registerPath({ method: "get", path: "/org/{orgId}/logs/analytics", description: "Query the request audit analytics for an organization", tags: [OpenAPITags.Logs], request: { query: queryAccessAuditLogsQuery, params: queryRequestAuditLogsParams }, responses: {} }); export type QueryRequestAnalyticsResponse = Awaited>; export async function queryRequestAnalytics( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const params = { ...parsedQuery.data, ...parsedParams.data }; const data = await query(params); return response(res, { data, success: true, error: false, message: "Request audit analytics retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/auditLogs/queryRequestAuditLog.ts ================================================ import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; import response from "@server/lib/response"; import logger from "@server/logger"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; export const queryAccessAuditLogsQuery = z.object({ // iso string just validate its a parseable date timeStart: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeStart must be a valid ISO date string" }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .prefault(() => getSevenDaysAgo().toISOString()) .openapi({ type: "string", format: "date-time", description: "Start time as ISO date string (defaults to 7 days ago)" }), timeEnd: z .string() .refine((val) => !isNaN(Date.parse(val)), { error: "timeEnd must be a valid ISO date string" }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() .prefault(() => new Date().toISOString()) .openapi({ type: "string", format: "date-time", description: "End time as ISO date string (defaults to current time)" }), action: z .union([z.boolean(), z.string()]) .transform((val) => (typeof val === "string" ? val === "true" : val)) .optional(), method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(), reason: z .string() .optional() .transform(Number) .pipe(z.int().positive()) .optional(), resourceId: z .string() .optional() .transform(Number) .pipe(z.int().positive()) .optional(), actor: z.string().optional(), location: z.string().optional(), host: z.string().optional(), path: z.string().optional(), limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); export const queryRequestAuditLogsParams = z.object({ orgId: z.string() }); export const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge( queryRequestAuditLogsParams ); type Q = z.infer; function getWhere(data: Q) { return and( gt(requestAuditLog.timestamp, data.timeStart), lt(requestAuditLog.timestamp, data.timeEnd), eq(requestAuditLog.orgId, data.orgId), data.resourceId ? eq(requestAuditLog.resourceId, data.resourceId) : undefined, data.actor ? eq(requestAuditLog.actor, data.actor) : undefined, data.method ? eq(requestAuditLog.method, data.method) : undefined, data.reason ? eq(requestAuditLog.reason, data.reason) : undefined, data.host ? eq(requestAuditLog.host, data.host) : undefined, data.location ? eq(requestAuditLog.location, data.location) : undefined, data.path ? eq(requestAuditLog.path, data.path) : undefined, data.action !== undefined ? eq(requestAuditLog.action, data.action) : undefined ); } export function queryRequest(data: Q) { return primaryLogsDb .select({ id: requestAuditLog.id, timestamp: requestAuditLog.timestamp, orgId: requestAuditLog.orgId, action: requestAuditLog.action, reason: requestAuditLog.reason, actorType: requestAuditLog.actorType, actor: requestAuditLog.actor, actorId: requestAuditLog.actorId, resourceId: requestAuditLog.resourceId, ip: requestAuditLog.ip, location: requestAuditLog.location, userAgent: requestAuditLog.userAgent, metadata: requestAuditLog.metadata, headers: requestAuditLog.headers, query: requestAuditLog.query, originalRequestURL: requestAuditLog.originalRequestURL, scheme: requestAuditLog.scheme, host: requestAuditLog.host, path: requestAuditLog.path, method: requestAuditLog.method, tls: requestAuditLog.tls }) .from(requestAuditLog) .where(getWhere(data)) .orderBy(desc(requestAuditLog.timestamp)); } async function enrichWithResourceDetails(logs: Awaited>) { // If logs database is the same as main database, we can do a join // Otherwise, we need to fetch resource details separately const resourceIds = logs .map(log => log.resourceId) .filter((id): id is number => id !== null && id !== undefined); if (resourceIds.length === 0) { return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); } // Fetch resource details from main database const resourceDetails = await primaryDb .select({ resourceId: resources.resourceId, name: resources.name, niceId: resources.niceId }) .from(resources) .where(inArray(resources.resourceId, resourceIds)); // Create a map for quick lookup const resourceMap = new Map( resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }]) ); // Enrich logs with resource details return logs.map(log => ({ ...log, resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null, resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null })); } export function countRequestQuery(data: Q) { const countQuery = primaryLogsDb .select({ count: count() }) .from(requestAuditLog) .where(getWhere(data)); return countQuery; } registry.registerPath({ method: "get", path: "/org/{orgId}/logs/request", description: "Query the request audit log for an organization", tags: [OpenAPITags.Logs], request: { query: queryAccessAuditLogsQuery, params: queryRequestAuditLogsParams }, responses: {} }); async function queryUniqueFilterAttributes( timeStart: number, timeEnd: number, orgId: string ) { const baseConditions = and( gt(requestAuditLog.timestamp, timeStart), lt(requestAuditLog.timestamp, timeEnd), eq(requestAuditLog.orgId, orgId) ); const DISTINCT_LIMIT = 500; // TODO: SOMEONE PLEASE OPTIMIZE THIS!!!!! // Run all queries in parallel const [ uniqueActors, uniqueLocations, uniqueHosts, uniquePaths, uniqueResources ] = await Promise.all([ primaryLogsDb .selectDistinct({ actor: requestAuditLog.actor }) .from(requestAuditLog) .where(baseConditions) .limit(DISTINCT_LIMIT + 1), primaryLogsDb .selectDistinct({ locations: requestAuditLog.location }) .from(requestAuditLog) .where(baseConditions) .limit(DISTINCT_LIMIT + 1), primaryLogsDb .selectDistinct({ hosts: requestAuditLog.host }) .from(requestAuditLog) .where(baseConditions) .limit(DISTINCT_LIMIT + 1), primaryLogsDb .selectDistinct({ paths: requestAuditLog.path }) .from(requestAuditLog) .where(baseConditions) .limit(DISTINCT_LIMIT + 1), primaryLogsDb .selectDistinct({ id: requestAuditLog.resourceId }) .from(requestAuditLog) .where(baseConditions) .limit(DISTINCT_LIMIT + 1) ]); // TODO: for stuff like the paths this is too restrictive so lets just show some of the paths and the user needs to // refine the time range to see what they need to see // if ( // uniqueActors.length > DISTINCT_LIMIT || // uniqueLocations.length > DISTINCT_LIMIT || // uniqueHosts.length > DISTINCT_LIMIT || // uniquePaths.length > DISTINCT_LIMIT || // uniqueResources.length > DISTINCT_LIMIT // ) { // throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range."); // } // Fetch resource names from main database for the unique resource IDs const resourceIds = uniqueResources .map(row => row.id) .filter((id): id is number => id !== null); let resourcesWithNames: Array<{ id: number; name: string | null }> = []; if (resourceIds.length > 0) { const resourceDetails = await primaryDb .select({ resourceId: resources.resourceId, name: resources.name }) .from(resources) .where(inArray(resources.resourceId, resourceIds)); resourcesWithNames = resourceDetails.map(r => ({ id: r.resourceId, name: r.name })); } return { actors: uniqueActors .map((row) => row.actor) .filter((actor): actor is string => actor !== null), resources: resourcesWithNames, locations: uniqueLocations .map((row) => row.locations) .filter((location): location is string => location !== null), hosts: uniqueHosts .map((row) => row.hosts) .filter((host): host is string => host !== null), paths: uniquePaths .map((row) => row.paths) .filter((path): path is string => path !== null) }; } export async function queryRequestAuditLogs( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const data = { ...parsedQuery.data, ...parsedParams.data }; const baseQuery = queryRequest(data); const logsRaw = await baseQuery.limit(data.limit).offset(data.offset); // Enrich with resource details (handles cross-database scenario) const log = await enrichWithResourceDetails(logsRaw); const totalCountResult = await countRequestQuery(data); const totalCount = totalCountResult[0].count; const filterAttributes = await queryUniqueFilterAttributes( data.timeStart, data.timeEnd, data.orgId ); return response(res, { data: { log: log, pagination: { total: totalCount, limit: data.limit, offset: data.offset }, filterAttributes }, success: true, error: false, message: "Request audit logs retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); // if the message is "Too many distinct filter attributes to retrieve. Please refine your time range.", return a 400 and the message if ( error instanceof Error && error.message === "Too many distinct filter attributes to retrieve. Please refine your time range." ) { return next(createHttpError(HttpCode.BAD_REQUEST, error.message)); } return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/auditLogs/types.ts ================================================ export type QueryActionAuditLogResponse = { log: { orgId: string; action: string; actorType: string; actorId: string; metadata: string | null; timestamp: number; actor: string; }[]; pagination: { total: number; limit: number; offset: number; }; filterAttributes: { actors: string[]; }; }; export type QueryRequestAuditLogResponse = { log: { timestamp: number; action: boolean; reason: number; orgId: string | null; actorType: string | null; actor: string | null; actorId: string | null; resourceId: number | null; resourceNiceId: string | null; resourceName: string | null; ip: string | null; location: string | null; userAgent: string | null; metadata: string | null; headers: string | null; query: string | null; originalRequestURL: string | null; scheme: string | null; host: string | null; path: string | null; method: string | null; tls: boolean | null; }[]; pagination: { total: number; limit: number; offset: number; }; filterAttributes: { actors: string[]; resources: { id: number; name: string | null; }[]; locations: string[]; hosts: string[]; paths: string[]; }; }; export type QueryAccessAuditLogResponse = { log: { orgId: string; action: boolean; actorType: string | null; actorId: string | null; resourceId: number | null; resourceName: string | null; resourceNiceId: string | null; ip: string | null; location: string | null; userAgent: string | null; metadata: string | null; type: string; timestamp: number; actor: string | null; }[]; pagination: { total: number; limit: number; offset: number; }; filterAttributes: { actors: string[]; resources: { id: number; name: string | null; }[]; locations: string[]; }; }; ================================================ FILE: server/routers/auth/changePassword.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import { z } from "zod"; import { db } from "@server/db"; import { User, users } from "@server/db"; import { response } from "@server/lib/response"; import { hashPassword, verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import logger from "@server/logger"; import { unauthorized } from "@server/auth/unauthorizedResponse"; import { invalidateAllSessions } from "@server/auth/sessions/app"; import { sessions, resourceSessions } from "@server/db"; import { and, eq, ne, inArray } from "drizzle-orm"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; import { sendEmail } from "@server/emails"; import ConfirmPasswordReset from "@server/emails/templates/NotifyResetPassword"; import config from "@server/lib/config"; export const changePasswordBody = z.strictObject({ oldPassword: z.string(), newPassword: passwordSchema, code: z.string().optional() }); export type ChangePasswordBody = z.infer; export type ChangePasswordResponse = { codeRequested?: boolean; }; async function invalidateAllSessionsExceptCurrent( userId: string, currentSessionId: string ): Promise { try { await db.transaction(async (trx) => { // Get all user sessions except the current one const userSessions = await trx .select() .from(sessions) .where( and( eq(sessions.userId, userId), ne(sessions.sessionId, currentSessionId) ) ); // Delete resource sessions for the sessions we're invalidating if (userSessions.length > 0) { await trx.delete(resourceSessions).where( inArray( resourceSessions.userSessionId, userSessions.map((s) => s.sessionId) ) ); } // Delete the user sessions (except current) await trx .delete(sessions) .where( and( eq(sessions.userId, userId), ne(sessions.sessionId, currentSessionId) ) ); }); } catch (e) { logger.error("Failed to invalidate user sessions except current", e); } } export async function changePassword( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = changePasswordBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { newPassword, oldPassword, code } = parsedBody.data; const user = req.user as User; if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Two-factor authentication is not supported for external users" ) ); } try { if (newPassword === oldPassword) { return next( createHttpError( HttpCode.BAD_REQUEST, "New password cannot be the same as the old password" ) ); } const validPassword = await verifyPassword( oldPassword, user.passwordHash! ); if (!validPassword) { return next(unauthorized()); } if (user.twoFactorEnabled) { if (!code) { return response<{ codeRequested: boolean }>(res, { data: { codeRequested: true }, success: true, error: false, message: "Two-factor authentication required", status: HttpCode.ACCEPTED }); } const validOTP = await verifyTotpCode( code!, user.twoFactorSecret!, user.userId ); if (!validOTP) { return next( createHttpError( HttpCode.BAD_REQUEST, "The two-factor code you entered is incorrect" ) ); } } const hash = await hashPassword(newPassword); await db .update(users) .set({ passwordHash: hash, lastPasswordChange: new Date().getTime() }) .where(eq(users.userId, user.userId)); // Invalidate all sessions except the current one await invalidateAllSessionsExceptCurrent( user.userId, req.session.sessionId ); try { const email = user.email!; await sendEmail(ConfirmPasswordReset({ email }), { from: config.getNoReplyEmail(), to: email, subject: "Password Reset Confirmation" }); } catch (e) { logger.error("Failed to send password reset confirmation email", e); } return response(res, { data: null, success: true, error: false, message: "Password changed successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to authenticate user" ) ); } } ================================================ FILE: server/routers/auth/checkResourceSession.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib/response"; import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import logger from "@server/logger"; export const params = z.strictObject({ token: z.string(), resourceId: z.string().transform(Number).pipe(z.int().positive()) }); export type CheckResourceSessionParams = z.infer; export type CheckResourceSessionResponse = { valid: boolean; }; export async function checkResourceSession( req: Request, res: Response, next: NextFunction ): Promise { const parsedParams = params.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { token, resourceId } = parsedParams.data; try { const { resourceSession } = await validateResourceSessionToken( token, resourceId ); let valid = false; if (resourceSession) { valid = true; } return response(res, { data: { valid }, success: true, error: false, message: "Checked validity", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to reset password" ) ); } } ================================================ FILE: server/routers/auth/deleteMyAccount.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, orgs, userOrgs, users } from "@server/db"; import { eq, and, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { verifySession } from "@server/auth/sessions/verifySession"; import { invalidateSession, createBlankSessionTokenCookie } from "@server/auth/sessions/app"; 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 } from "@server/lib/deleteOrg"; import { UserType } from "@server/types/UserTypes"; const deleteMyAccountBody = z.strictObject({ password: z.string().optional(), code: z.string().optional() }); export type DeleteMyAccountPreviewResponse = { preview: true; orgs: { orgId: string; name: string }[]; twoFactorEnabled: boolean; }; export type DeleteMyAccountCodeRequestedResponse = { codeRequested: true; }; export type DeleteMyAccountSuccessResponse = { success: true; }; export async function deleteMyAccount( req: Request, res: Response, next: NextFunction ): Promise { try { const { user, session } = await verifySession(req); if (!user || !session) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated") ); } if (user.serverAdmin) { return next( createHttpError( HttpCode.BAD_REQUEST, "Server admins cannot delete their account this way" ) ); } if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Account deletion with password is only supported for internal users" ) ); } const parsed = deleteMyAccountBody.safeParse(req.body ?? {}); if (!parsed.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsed.error).toString() ) ); } const { password, code } = parsed.data; const userId = user.userId; const ownedOrgsRows = await db .select({ 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)) ); 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 ? await db .select({ orgId: orgs.orgId, name: orgs.name }) .from(orgs) .where(inArray(orgs.orgId, orgIds)) : []; return response(res, { data: { preview: true, orgs: orgsWithNames.map((o) => ({ orgId: o.orgId, name: o.name ?? "" })), twoFactorEnabled: user.twoFactorEnabled ?? false }, success: true, error: false, message: "Preview", status: HttpCode.OK }); } const validPassword = await verifyPassword( password, user.passwordHash! ); if (!validPassword) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Invalid password") ); } if (user.twoFactorEnabled) { if (!code) { return response(res, { data: { codeRequested: true }, success: true, error: false, message: "Two-factor code required", status: HttpCode.ACCEPTED }); } const validOTP = await verifyTotpCode( code, user.twoFactorSecret!, user.userId ); if (!validOTP) { return next( createHttpError( HttpCode.BAD_REQUEST, "The two-factor code you entered is incorrect" ) ); } } const allDeletedNewtIds: string[] = []; const allOlmsToTerminate: string[] = []; for (const row of ownedOrgsRows) { try { const result = await deleteOrgById(row.orgId); allDeletedNewtIds.push(...result.deletedNewtIds); allOlmsToTerminate.push(...result.olmsToTerminate); } catch (err) { logger.error( `Failed to delete org ${row.orgId} during account deletion`, err ); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to delete organization" ) ); } } sendTerminationMessages({ deletedNewtIds: allDeletedNewtIds, olmsToTerminate: allOlmsToTerminate }); await db.transaction(async (trx) => { await trx.delete(users).where(eq(users.userId, userId)); await calculateUserClientsForOrgs(userId, trx); }); try { await invalidateSession(session.sessionId); } catch (error) { logger.error( "Failed to invalidate session after account deletion", error ); } const isSecure = req.protocol === "https"; res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); return response(res, { data: { success: true }, success: true, error: false, message: "Account deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred" ) ); } } ================================================ FILE: server/routers/auth/disable2fa.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import { z } from "zod"; import { db } from "@server/db"; import { User, users } from "@server/db"; import { eq } from "drizzle-orm"; import { response } from "@server/lib/response"; import { verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import logger from "@server/logger"; import { sendEmail } from "@server/emails"; import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification"; import config from "@server/lib/config"; import { unauthorized } from "@server/auth/unauthorizedResponse"; import { UserType } from "@server/types/UserTypes"; export const disable2faBody = z.strictObject({ password: z.string(), code: z.string().optional() }); export type Disable2faBody = z.infer; export type Disable2faResponse = { codeRequested?: boolean; }; export async function disable2fa( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = disable2faBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { password, code } = parsedBody.data; const user = req.user as User; if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Two-factor authentication is not supported for external users" ) ); } try { const validPassword = await verifyPassword( password, user.passwordHash! ); if (!validPassword) { return next(unauthorized()); } if (!user.twoFactorEnabled) { return next( createHttpError( HttpCode.BAD_REQUEST, "Two-factor authentication is already disabled" ) ); } else { if (!code) { return response<{ codeRequested: boolean }>(res, { data: { codeRequested: true }, success: true, error: false, message: "Two-factor authentication required", status: HttpCode.ACCEPTED }); } } const validOTP = await verifyTotpCode( code, user.twoFactorSecret!, user.userId ); if (!validOTP) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.BAD_REQUEST, "The two-factor code you entered is incorrect" ) ); } await db .update(users) .set({ twoFactorEnabled: false }) .where(eq(users.userId, user.userId)); sendEmail( TwoFactorAuthNotification({ email: user.email!, // email is not null because we are checking user.type enabled: false }), { to: user.email!, from: config.getRawConfig().email?.no_reply, subject: "Two-factor authentication disabled" } ); return response(res, { data: null, success: true, error: false, message: "Two-factor authentication disabled", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to disable two-factor authentication" ) ); } } ================================================ FILE: server/routers/auth/index.ts ================================================ export * from "./login"; export * from "./signup"; export * from "./logout"; export * from "./verifyTotp"; export * from "./requestTotpSecret"; export * from "./disable2fa"; export * from "./verifyEmail"; export * from "./requestEmailVerificationCode"; export * from "./resetPassword"; export * from "./requestPasswordReset"; export * from "./setServerAdmin"; export * from "./initialSetupComplete"; export * from "./validateSetupToken"; export * from "./changePassword"; export * from "./checkResourceSession"; export * from "./securityKey"; export * from "./startDeviceWebAuth"; export * from "./verifyDeviceWebAuth"; export * from "./pollDeviceWebAuth"; export * from "./lookupUser"; export * from "./deleteMyAccount"; ================================================ FILE: server/routers/auth/initialSetupComplete.ts ================================================ import { NextFunction, Request, Response } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response } from "@server/lib/response"; import { db, users } from "@server/db"; import { eq } from "drizzle-orm"; export type InitialSetupCompleteResponse = { complete: boolean; }; export async function initialSetupComplete( req: Request, res: Response, next: NextFunction ): Promise { try { const [existing] = await db .select() .from(users) .where(eq(users.serverAdmin, true)); return response(res, { data: { complete: !!existing }, success: true, error: false, message: "Initial setup check completed", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to check initial setup completion" ) ); } } ================================================ FILE: server/routers/auth/login.ts ================================================ import { createSession, generateSessionToken, invalidateSession, serializeSessionCookie, SESSION_COOKIE_NAME } from "@server/auth/sessions/app"; import { db, resources } from "@server/db"; import { users, securityKeys } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, and } 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 { verifyTotpCode } from "@server/auth/totp"; import config from "@server/lib/config"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import { verifySession } from "@server/auth/sessions/verifySession"; import { UserType } from "@server/types/UserTypes"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; export const loginBodySchema = z.strictObject({ email: z.email().toLowerCase(), password: z.string(), code: z.string().optional(), resourceGuid: z.string().optional() }); export type LoginBody = z.infer; export type LoginResponse = { codeRequested?: boolean; emailVerificationRequired?: boolean; useSecurityKey?: boolean; twoFactorSetupRequired?: boolean; }; export async function login( req: Request, res: Response, next: NextFunction ): Promise { const { forceLogin } = req.query; const { session: existingSession } = await verifySession( req, forceLogin === "true" ); if (existingSession) { return response(res, { data: null, success: true, error: false, message: "Already logged in", status: HttpCode.OK }); } const parsedBody = loginBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { email, password, code, resourceGuid } = parsedBody.data; try { let resourceId: number | null = null; let orgId: string | null = null; if (resourceGuid) { const [resource] = await db .select() .from(resources) .where(eq(resources.resourceGuid, resourceGuid)) .limit(1); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with GUID ${resourceGuid} not found` ) ); } resourceId = resource.resourceId; orgId = resource.orgId; } const existingUserRes = await db .select() .from(users) .where( and(eq(users.type, UserType.Internal), eq(users.email, email)) ); if (!existingUserRes || !existingUserRes.length) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` ); } if (resourceId && orgId) { logAccessAudit({ orgId: orgId, resourceId: resourceId, action: false, type: "login", userAgent: req.headers["user-agent"], requestIp: req.ip }); } return next( createHttpError( HttpCode.UNAUTHORIZED, "Username or password is incorrect" ) ); } const existingUser = existingUserRes[0]; const validPassword = await verifyPassword( password, existingUser.passwordHash! ); if (!validPassword) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` ); } if (resourceId && orgId) { logAccessAudit({ orgId: orgId, resourceId: resourceId, action: false, type: "login", userAgent: req.headers["user-agent"], requestIp: req.ip }); } return next( createHttpError( HttpCode.UNAUTHORIZED, "Username or password is incorrect" ) ); } // // Check if user has security keys registered // const userSecurityKeys = await db // .select() // .from(securityKeys) // .where(eq(securityKeys.userId, existingUser.userId)); // // if (userSecurityKeys.length > 0) { // return response(res, { // data: { useSecurityKey: true }, // success: true, // error: false, // message: "Security key authentication required", // status: HttpCode.OK // }); // } if ( existingUser.twoFactorSetupRequested && !existingUser.twoFactorEnabled ) { return response(res, { data: { twoFactorSetupRequired: true }, success: true, error: false, message: "Two-factor authentication setup required", status: HttpCode.ACCEPTED }); } if (existingUser.twoFactorEnabled) { if (!code) { return response<{ codeRequested: boolean }>(res, { data: { codeRequested: true }, success: true, error: false, message: "Two-factor authentication required", status: HttpCode.ACCEPTED }); } const validOTP = await verifyTotpCode( code, existingUser.twoFactorSecret!, existingUser.userId ); if (!validOTP) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.` ); } if (resourceId && orgId) { logAccessAudit({ orgId: orgId, resourceId: resourceId, action: false, type: "login", userAgent: req.headers["user-agent"], requestIp: req.ip }); } return next( createHttpError( HttpCode.UNAUTHORIZED, "The two-factor code you entered is incorrect" ) ); } } // check for previous cookie value and expire it const previousCookie = req.cookies[SESSION_COOKIE_NAME]; if (previousCookie) { await invalidateSession(previousCookie); } const token = generateSessionToken(); const sess = await createSession(token, existingUser.userId); const isSecure = req.protocol === "https"; const cookie = serializeSessionCookie( token, isSecure, new Date(sess.expiresAt) ); res.appendHeader("Set-Cookie", cookie); if ( !existingUser.emailVerified && config.getRawConfig().flags?.require_email_verification ) { return response(res, { data: { emailVerificationRequired: true }, success: true, error: false, message: "Email verification code sent", status: HttpCode.OK }); } return response(res, { data: null, success: true, error: false, message: "Logged in successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to authenticate user" ) ); } } ================================================ FILE: server/routers/auth/logout.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import logger from "@server/logger"; import { createBlankSessionTokenCookie, invalidateSession } from "@server/auth/sessions/app"; import { verifySession } from "@server/auth/sessions/verifySession"; import config from "@server/lib/config"; export async function logout( req: Request, res: Response, next: NextFunction ): Promise { const { user, session } = await verifySession(req); if (!user || !session) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Log out failed because missing or invalid session. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.BAD_REQUEST, "You must be logged in to sign out" ) ); } try { try { await invalidateSession(session.sessionId); } catch (error) { logger.error("Failed to invalidate session", error); } const isSecure = req.protocol === "https"; res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); return response(res, { data: null, success: true, error: false, message: "Logged out successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Failed to log out") ); } } ================================================ FILE: server/routers/auth/lookupUser.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { users, userOrgs, orgs, idpOrg, idp, idpOidcConfig } from "@server/db"; import { eq, or, sql, and, isNotNull, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { UserType } from "@server/types/UserTypes"; const lookupBodySchema = z.strictObject({ identifier: z.string().min(1).toLowerCase() }); export type LookupUserResponse = { found: boolean; identifier: string; accounts: Array<{ userId: string; email: string | null; username: string; hasInternalAuth: boolean; orgs: Array<{ orgId: string; orgName: string; idps: Array<{ idpId: number; name: string; variant: string | null; }>; hasInternalAuth: boolean; }>; }>; }; // registry.registerPath({ // method: "post", // path: "/auth/lookup-user", // description: "Lookup user accounts by username or email and return available authentication methods.", // tags: [OpenAPITags.Auth], // request: { // body: lookupBodySchema // }, // responses: {} // }); export async function lookupUser( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = lookupBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { identifier } = parsedBody.data; // Query users matching identifier (case-insensitive) // Match by username OR email const matchingUsers = await db .select({ userId: users.userId, email: users.email, username: users.username, type: users.type, passwordHash: users.passwordHash, idpId: users.idpId }) .from(users) .where( or( sql`LOWER(${users.username}) = ${identifier}`, sql`LOWER(${users.email}) = ${identifier}` ) ); if (!matchingUsers || matchingUsers.length === 0) { return response(res, { data: { found: false, identifier, accounts: [] }, success: true, error: false, message: "No accounts found", status: HttpCode.OK }); } // Get unique user IDs const userIds = [...new Set(matchingUsers.map((u) => u.userId))]; // Get all org memberships for these users const orgMemberships = await db .select({ userId: userOrgs.userId, orgId: userOrgs.orgId, orgName: orgs.name }) .from(userOrgs) .innerJoin(orgs, eq(orgs.orgId, userOrgs.orgId)) .where(inArray(userOrgs.userId, userIds)); // Get unique org IDs const orgIds = [...new Set(orgMemberships.map((m) => m.orgId))]; // Get all IdPs for these orgs const orgIdps = orgIds.length > 0 ? await db .select({ orgId: idpOrg.orgId, idpId: idp.idpId, idpName: idp.name, variant: idpOidcConfig.variant }) .from(idpOrg) .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) .innerJoin( idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId) ) .where(inArray(idpOrg.orgId, orgIds)) : []; // Build response structure const accounts: LookupUserResponse["accounts"] = []; for (const user of matchingUsers) { const hasInternalAuth = user.type === UserType.Internal && user.passwordHash !== null; // Get orgs for this user const userOrgMemberships = orgMemberships.filter( (m) => m.userId === user.userId ); // Deduplicate orgs (user might have multiple memberships in same org) const uniqueOrgs = new Map(); for (const membership of userOrgMemberships) { if (!uniqueOrgs.has(membership.orgId)) { uniqueOrgs.set(membership.orgId, membership); } } const orgsData = Array.from(uniqueOrgs.values()).map((membership) => { // Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP // Only show IdPs where the user's idpId matches // Internal users don't have an idpId, so they won't see any IdPs const orgIdpsList = orgIdps .filter((idp) => { if (idp.orgId !== membership.orgId) { return false; } // Only show IdPs where the user (with exact identifier) is authenticated via that IdP // This means user.idpId must match idp.idpId if (user.idpId !== null && user.idpId === idp.idpId) { return true; } return false; }) .map((idp) => ({ idpId: idp.idpId, name: idp.idpName, variant: idp.variant })); // Check if user has internal auth for this org // User has internal auth if they have an internal account type const orgHasInternalAuth = hasInternalAuth; return { orgId: membership.orgId, orgName: membership.orgName, idps: orgIdpsList, hasInternalAuth: orgHasInternalAuth }; }); accounts.push({ userId: user.userId, email: user.email, username: user.username, hasInternalAuth, orgs: orgsData }); } return response(res, { data: { found: true, identifier, accounts }, success: true, error: false, message: "User lookup completed", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/auth/pollDeviceWebAuth.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { response } from "@server/lib/response"; import { db, deviceWebAuthCodes } from "@server/db"; import { eq, and, gt } from "drizzle-orm"; import { createSession, generateSessionToken } from "@server/auth/sessions/app"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { stripPortFromHost } from "@server/lib/ip"; const paramsSchema = z.object({ code: z.string().min(1, "Code is required") }); export type PollDeviceWebAuthParams = z.infer; // Helper function to hash device code before querying database function hashDeviceCode(code: string): string { return encodeHexLowerCase(sha256(new TextEncoder().encode(code))); } export type PollDeviceWebAuthResponse = { verified: boolean; token?: string; }; export async function pollDeviceWebAuth( req: Request, res: Response, next: NextFunction ): Promise { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } try { const { code } = parsedParams.data; const now = Date.now(); const requestIp = req.ip ? stripPortFromHost(req.ip) : undefined; // Hash the code before querying const hashedCode = hashDeviceCode(code); // Find the code in the database const [deviceCode] = await db .select() .from(deviceWebAuthCodes) .where(eq(deviceWebAuthCodes.code, hashedCode)) .limit(1); if (!deviceCode) { return response(res, { data: { verified: false }, success: true, error: false, message: "Code not found", status: HttpCode.OK }); } // Check if code is expired if (deviceCode.expiresAt <= now) { return response(res, { data: { verified: false }, success: true, error: false, message: "Code expired", status: HttpCode.OK }); } // Check if code is verified if (!deviceCode.verified) { return response(res, { data: { verified: false }, success: true, error: false, message: "Code not yet verified", status: HttpCode.OK }); } // Check if userId is set (should be set when verified) if (!deviceCode.userId) { logger.error("Device code is verified but userId is missing", { codeId: deviceCode.codeId }); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Invalid code state" ) ); } // Generate session token const token = generateSessionToken(); await createSession(token, deviceCode.userId); // Delete the code after successful exchange for a token await db .delete(deviceWebAuthCodes) .where(eq(deviceWebAuthCodes.codeId, deviceCode.codeId)); return response(res, { data: { verified: true, token }, success: true, error: false, message: "Code verified and session created", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to poll device code" ) ); } } ================================================ FILE: server/routers/auth/requestEmailVerificationCode.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib/response"; import { User } from "@server/db"; import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode"; import config from "@server/lib/config"; import logger from "@server/logger"; import { UserType } from "@server/types/UserTypes"; export type RequestEmailVerificationCodeResponse = { codeSent: boolean; }; export async function requestEmailVerificationCode( req: Request, res: Response, next: NextFunction ): Promise { if (!config.getRawConfig().flags?.require_email_verification) { return next( createHttpError( HttpCode.BAD_REQUEST, "Email verification is not enabled" ) ); } try { const user = req.user as User; if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Email verification is not supported for external users" ) ); } if (user.emailVerified) { return next( createHttpError( HttpCode.BAD_REQUEST, "Email is already verified" ) ); } await sendEmailVerificationCode(user.email!, user.userId); return response(res, { data: { codeSent: true }, status: HttpCode.OK, success: true, error: false, message: `Email verification code sent to ${user.email}` }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to send email verification code" ) ); } } export default requestEmailVerificationCode; ================================================ FILE: server/routers/auth/requestPasswordReset.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib/response"; import { db } from "@server/db"; import { passwordResetTokens, users } from "@server/db"; import { eq } from "drizzle-orm"; import { alphabet, generateRandomString, sha256 } from "oslo/crypto"; import { createDate } from "oslo"; import logger from "@server/logger"; import { TimeSpan } from "oslo"; import config from "@server/lib/config"; import { sendEmail } from "@server/emails"; import ResetPasswordCode from "@server/emails/templates/ResetPasswordCode"; import { hashPassword } from "@server/auth/password"; import { UserType } from "@server/types/UserTypes"; export const requestPasswordResetBody = z.strictObject({ email: z.email().toLowerCase() }); export type RequestPasswordResetBody = z.infer; export type RequestPasswordResetResponse = { sentEmail: boolean; }; export async function requestPasswordReset( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = requestPasswordResetBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { email } = parsedBody.data; try { const existingUser = await db .select() .from(users) .where(eq(users.email, email)); if (!existingUser || !existingUser.length) { await randomDelay(2000); logger.debug( `Password reset requested for ${email}, but no such user exists` ); return response(res, { data: { sentEmail: true }, success: true, error: false, message: "Password reset requested", status: HttpCode.OK }); } if (existingUser[0].type !== UserType.Internal) { await randomDelay(2000); logger.debug( `Password reset requested for ${email}, but user is of type ${existingUser[0].type}` ); return response(res, { data: { sentEmail: true }, success: true, error: false, message: "Password reset requested", status: HttpCode.OK }); } const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); await db.transaction(async (trx) => { await trx .delete(passwordResetTokens) .where(eq(passwordResetTokens.userId, existingUser[0].userId)); const tokenHash = await hashPassword(token); await trx.insert(passwordResetTokens).values({ userId: existingUser[0].userId, email: existingUser[0].email!, tokenHash, expiresAt: createDate(new TimeSpan(2, "h")).getTime() }); }); const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`; if (!config.getRawConfig().email) { logger.info( `Password reset requested for ${email}. Token: ${token}.` ); } await sendEmail( ResetPasswordCode({ email, code: token, link: url }), { from: config.getNoReplyEmail(), to: email, subject: "Reset your password" } ); return response(res, { data: { sentEmail: true }, success: true, error: false, message: "Password reset requested", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to process password reset request" ) ); } } async function randomDelay(maxDelayMs: number) { const delay = Math.floor(Math.random() * maxDelayMs); return new Promise((resolve) => setTimeout(resolve, delay)); } ================================================ FILE: server/routers/auth/requestTotpSecret.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { encodeHex } from "oslo/encoding"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib/response"; import { db } from "@server/db"; import { User, users } from "@server/db"; import { eq, and } from "drizzle-orm"; import { createTOTPKeyURI } from "oslo/otp"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import { unauthorized } from "@server/auth/unauthorizedResponse"; import { UserType } from "@server/types/UserTypes"; import { verifySession } from "@server/auth/sessions/verifySession"; import config from "@server/lib/config"; export const requestTotpSecretBody = z.strictObject({ password: z.string(), email: z.email().optional() }); export type RequestTotpSecretBody = z.infer; export type RequestTotpSecretResponse = { secret: string; uri: string; }; export async function requestTotpSecret( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = requestTotpSecretBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { password, email } = parsedBody.data; const { user: sessionUser, session: existingSession } = await verifySession(req); let user: User | null = sessionUser; if (!existingSession) { if (!email) { return next( createHttpError( HttpCode.BAD_REQUEST, "Email is required for two-factor authentication setup" ) ); } const [res] = await db .select() .from(users) .where( and(eq(users.type, UserType.Internal), eq(users.email, email)) ); user = res; } if (!user) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.UNAUTHORIZED, "Username or password is incorrect" ) ); } if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Two-factor authentication is not supported for external users" ) ); } try { const validPassword = await verifyPassword( password, user.passwordHash! ); if (!validPassword) { return next(unauthorized()); } if (user.twoFactorEnabled) { return next( createHttpError( HttpCode.BAD_REQUEST, "User has already enabled two-factor authentication" ) ); } const appName = process.env.BRANDING_APP_NAME || "Pangolin"; // From the private config loading into env vars to seperate away the private config const hex = crypto.getRandomValues(new Uint8Array(20)); const secret = encodeHex(hex); const uri = createTOTPKeyURI(appName, user.email!, hex); await db .update(users) .set({ twoFactorSecret: secret }) .where(eq(users.userId, user.userId)); return response(res, { data: { secret, uri }, success: true, error: false, message: "TOTP secret generated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to generate TOTP secret" ) ); } } ================================================ FILE: server/routers/auth/resetPassword.ts ================================================ import config from "@server/lib/config"; import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib/response"; import { db } from "@server/db"; import { passwordResetTokens, users } from "@server/db"; import { eq } from "drizzle-orm"; import { hashPassword, verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import { isWithinExpirationDate } from "oslo"; import { invalidateAllSessions } from "@server/auth/sessions/app"; import logger from "@server/logger"; import ConfirmPasswordReset from "@server/emails/templates/NotifyResetPassword"; import { sendEmail } from "@server/emails"; import { passwordSchema } from "@server/auth/passwordSchema"; export const resetPasswordBody = z.strictObject({ email: z.email().toLowerCase(), token: z.string(), // reset secret code newPassword: passwordSchema, code: z.string().optional() // 2fa code }); export type ResetPasswordBody = z.infer; export type ResetPasswordResponse = { codeRequested?: boolean; }; export async function resetPassword( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = resetPasswordBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { token, newPassword, code, email } = parsedBody.data; try { const resetRequest = await db .select() .from(passwordResetTokens) .where(eq(passwordResetTokens.email, email)); if (!resetRequest || !resetRequest.length) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid password reset token" ) ); } if (!isWithinExpirationDate(new Date(resetRequest[0].expiresAt))) { return next( createHttpError( HttpCode.BAD_REQUEST, "Password reset token has expired" ) ); } const user = await db .select() .from(users) .where(eq(users.userId, resetRequest[0].userId)); if (!user || !user.length) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "User not found" ) ); } if (user[0].twoFactorEnabled) { if (!code) { return response(res, { data: { codeRequested: true }, success: true, error: false, message: "Two-factor authentication required", status: HttpCode.ACCEPTED }); } const validOTP = await verifyTotpCode( code!, user[0].twoFactorSecret!, user[0].userId ); if (!validOTP) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Two-factor authentication code is incorrect. Email: ${email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid two-factor authentication code" ) ); } } const isTokenValid = await verifyPassword( token, resetRequest[0].tokenHash ); if (!isTokenValid) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid password reset token" ) ); } const passwordHash = await hashPassword(newPassword); await db.transaction(async (trx) => { await trx .update(users) .set({ passwordHash, lastPasswordChange: new Date().getTime() }) .where(eq(users.userId, resetRequest[0].userId)); await trx .delete(passwordResetTokens) .where(eq(passwordResetTokens.email, email)); }); try { await invalidateAllSessions(resetRequest[0].userId); } catch (e) { logger.error("Failed to invalidate user sessions", e); } try { await sendEmail(ConfirmPasswordReset({ email }), { from: config.getNoReplyEmail(), to: email, subject: "Password Reset Confirmation" }); } catch (e) { logger.error("Failed to send password reset confirmation email", e); } return response(res, { data: null, success: true, error: false, message: "Password reset successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to reset password" ) ); } } ================================================ FILE: server/routers/auth/securityKey.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import { z } from "zod"; import { db } from "@server/db"; import { User, securityKeys, users, webauthnChallenge } from "@server/db"; import { eq, and, lt } from "drizzle-orm"; import { response } from "@server/lib/response"; import logger from "@server/logger"; import { generateRegistrationOptions, verifyRegistrationResponse, generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server"; import type { GenerateRegistrationOptionsOpts, GenerateAuthenticationOptionsOpts, AuthenticatorTransportFuture } from "@simplewebauthn/server"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; import { verifyPassword } from "@server/auth/password"; import { unauthorized } from "@server/auth/unauthorizedResponse"; import { verifyTotpCode } from "@server/auth/totp"; // The RP ID is the domain name of your application const rpID = (() => { const url = config.getRawConfig().app.dashboard_url ? new URL(config.getRawConfig().app.dashboard_url!) : undefined; // For localhost, we must use 'localhost' without port if (url?.hostname === "localhost" || !url) { return "localhost"; } return url.hostname; })(); const rpName = "Pangolin"; const origin = config.getRawConfig().app.dashboard_url || "localhost"; // Database-based challenge storage (replaces in-memory storage) // Challenges are stored in the webauthnChallenge table with automatic expiration // This supports clustered deployments and persists across server restarts // Clean up expired challenges every 5 minutes setInterval( async () => { try { const now = Date.now(); await db .delete(webauthnChallenge) .where(lt(webauthnChallenge.expiresAt, now)); // logger.debug("Cleaned up expired security key challenges"); } catch (error) { logger.error( "Failed to clean up expired security key challenges", error ); } }, 5 * 60 * 1000 ); // Helper functions for challenge management async function storeChallenge( sessionId: string, challenge: string, securityKeyName?: string, userId?: string ) { const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes // Delete any existing challenge for this session await db .delete(webauthnChallenge) .where(eq(webauthnChallenge.sessionId, sessionId)); // Insert new challenge await db.insert(webauthnChallenge).values({ sessionId, challenge, securityKeyName, userId, expiresAt }); } async function getChallenge(sessionId: string) { const [challengeData] = await db .select() .from(webauthnChallenge) .where(eq(webauthnChallenge.sessionId, sessionId)) .limit(1); if (!challengeData) { return null; } // Check if expired if (challengeData.expiresAt < Date.now()) { await db .delete(webauthnChallenge) .where(eq(webauthnChallenge.sessionId, sessionId)); return null; } return challengeData; } async function clearChallenge(sessionId: string) { await db .delete(webauthnChallenge) .where(eq(webauthnChallenge.sessionId, sessionId)); } export const registerSecurityKeyBody = z.strictObject({ name: z.string().min(1), password: z.string().min(1), code: z.string().optional() }); export const verifyRegistrationBody = z.strictObject({ credential: z.any() }); export const startAuthenticationBody = z.strictObject({ email: z.email().optional() }); export const verifyAuthenticationBody = z.strictObject({ credential: z.any() }); export const deleteSecurityKeyBody = z.strictObject({ password: z.string().min(1), code: z.string().optional() }); export async function startRegistration( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = registerSecurityKeyBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { name, password, code } = parsedBody.data; const user = req.user as User; // Only allow internal users to use security keys if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Security keys are only available for internal users" ) ); } try { // Verify password const validPassword = await verifyPassword( password, user.passwordHash! ); if (!validPassword) { return next(unauthorized()); } // If user has 2FA enabled, require and verify the code if (user.twoFactorEnabled) { if (!code) { return response<{ codeRequested: boolean }>(res, { data: { codeRequested: true }, success: true, error: false, message: "Two-factor authentication required", status: HttpCode.ACCEPTED }); } const validOTP = await verifyTotpCode( code, user.twoFactorSecret!, user.userId ); if (!validOTP) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.UNAUTHORIZED, "The two-factor code you entered is incorrect" ) ); } } // Get existing security keys for user const existingSecurityKeys = await db .select() .from(securityKeys) .where(eq(securityKeys.userId, user.userId)); const excludeCredentials = existingSecurityKeys.map((key) => ({ id: key.credentialId, transports: key.transports ? (JSON.parse(key.transports) as AuthenticatorTransportFuture[]) : undefined })); const options: GenerateRegistrationOptionsOpts = { rpName, rpID, userID: isoBase64URL.toBuffer(user.userId), userName: user.email || user.username, attestationType: "none", excludeCredentials, authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" } }; const registrationOptions = await generateRegistrationOptions(options); // Store challenge in database await storeChallenge( req.session.sessionId, registrationOptions.challenge, name, user.userId ); return response(res, { data: registrationOptions, success: true, error: false, message: "Registration options generated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to start registration" ) ); } } export async function verifyRegistration( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = verifyRegistrationBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { credential } = parsedBody.data; const user = req.user as User; // Only allow internal users to use security keys if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Security keys are only available for internal users" ) ); } try { // Get challenge from database const challengeData = await getChallenge(req.session.sessionId); if (!challengeData) { return next( createHttpError( HttpCode.BAD_REQUEST, "No challenge found in session or challenge expired" ) ); } const verification = await verifyRegistrationResponse({ response: credential, expectedChallenge: challengeData.challenge, expectedOrigin: origin, expectedRPID: rpID, requireUserVerification: false }); const { verified, registrationInfo } = verification; if (!verified || !registrationInfo) { return next( createHttpError(HttpCode.BAD_REQUEST, "Verification failed") ); } // Store the security key in the database await db.insert(securityKeys).values({ credentialId: registrationInfo.credential.id, userId: user.userId, publicKey: isoBase64URL.fromBuffer( registrationInfo.credential.publicKey ), signCount: registrationInfo.credential.counter || 0, transports: registrationInfo.credential.transports ? JSON.stringify(registrationInfo.credential.transports) : null, name: challengeData.securityKeyName, lastUsed: new Date().toISOString(), dateCreated: new Date().toISOString() }); // Clear challenge data await clearChallenge(req.session.sessionId); return response(res, { data: null, success: true, error: false, message: "Security key registered successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to verify registration" ) ); } } export async function listSecurityKeys( req: Request, res: Response, next: NextFunction ): Promise { const user = req.user as User; // Only allow internal users to use security keys if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Security keys are only available for internal users" ) ); } try { const userSecurityKeys = await db .select() .from(securityKeys) .where(eq(securityKeys.userId, user.userId)); return response(res, { data: userSecurityKeys, success: true, error: false, message: "Security keys retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to retrieve security keys" ) ); } } export async function deleteSecurityKey( req: Request, res: Response, next: NextFunction ): Promise { const { credentialId: encodedCredentialId } = req.params; const credentialId = decodeURIComponent(encodedCredentialId); const user = req.user as User; const parsedBody = deleteSecurityKeyBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { password, code } = parsedBody.data; // Only allow internal users to use security keys if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Security keys are only available for internal users" ) ); } try { // Verify password const validPassword = await verifyPassword( password, user.passwordHash! ); if (!validPassword) { return next(unauthorized()); } // If user has 2FA enabled, require and verify the code if (user.twoFactorEnabled) { if (!code) { return response<{ codeRequested: boolean }>(res, { data: { codeRequested: true }, success: true, error: false, message: "Two-factor authentication required", status: HttpCode.ACCEPTED }); } const validOTP = await verifyTotpCode( code, user.twoFactorSecret!, user.userId ); if (!validOTP) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.UNAUTHORIZED, "The two-factor code you entered is incorrect" ) ); } } await db .delete(securityKeys) .where( and( eq(securityKeys.credentialId, credentialId), eq(securityKeys.userId, user.userId) ) ); return response(res, { data: null, success: true, error: false, message: "Security key deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to delete security key" ) ); } } export async function startAuthentication( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = startAuthenticationBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { email } = parsedBody.data; try { let allowCredentials; let userId; // If email is provided, get security keys for that specific user if (email) { const [user] = await db .select() .from(users) .where(eq(users.email, email)) .limit(1); if (!user || user.type !== UserType.Internal) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid credentials") ); } userId = user.userId; const userSecurityKeys = await db .select() .from(securityKeys) .where(eq(securityKeys.userId, user.userId)); if (userSecurityKeys.length === 0) { return next( createHttpError( HttpCode.BAD_REQUEST, "No security keys registered for this user" ) ); } allowCredentials = userSecurityKeys.map((key) => ({ id: key.credentialId, transports: key.transports ? (JSON.parse( key.transports ) as AuthenticatorTransportFuture[]) : undefined })); } const options: GenerateAuthenticationOptionsOpts = { rpID, allowCredentials, userVerification: "preferred" }; const authenticationOptions = await generateAuthenticationOptions(options); // Generate a temporary session ID for unauthenticated users const tempSessionId = email ? `temp_${email}_${Date.now()}` : `temp_${Date.now()}`; // Store challenge in database await storeChallenge( tempSessionId, authenticationOptions.challenge, undefined, userId ); return response(res, { data: { ...authenticationOptions, tempSessionId }, success: true, error: false, message: "Authentication options generated", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to generate authentication options" ) ); } } export async function verifyAuthentication( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = verifyAuthenticationBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { credential } = parsedBody.data; const tempSessionId = req.headers["x-temp-session-id"] as string; if (!tempSessionId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Your session information is missing. This might happen if you've been inactive for too long or if your browser cleared temporary data. Please start the sign-in process again." ) ); } try { // Get challenge from database const challengeData = await getChallenge(tempSessionId); if (!challengeData) { return next( createHttpError( HttpCode.BAD_REQUEST, "Your sign-in session has expired. For security reasons, you have 5 minutes to complete the authentication process. Please try signing in again." ) ); } // Find the security key in database const credentialId = credential.id; const [securityKey] = await db .select() .from(securityKeys) .where(eq(securityKeys.credentialId, credentialId)) .limit(1); if (!securityKey) { return next( createHttpError( HttpCode.BAD_REQUEST, "We couldn't verify your security key. This might happen if your device isn't compatible or if the security key was removed too quickly. Please try again and keep your security key connected until the process completes." ) ); } // Get the user const [user] = await db .select() .from(users) .where(eq(users.userId, securityKey.userId)) .limit(1); if (!user || user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "User not found or not authorized for security key authentication" ) ); } const verification = await verifyAuthenticationResponse({ response: credential, expectedChallenge: challengeData.challenge, expectedOrigin: origin, expectedRPID: rpID, credential: { id: securityKey.credentialId, publicKey: isoBase64URL.toBuffer(securityKey.publicKey), counter: securityKey.signCount, transports: securityKey.transports ? (JSON.parse( securityKey.transports ) as AuthenticatorTransportFuture[]) : undefined }, requireUserVerification: false }); const { verified, authenticationInfo } = verification; if (!verified) { return next( createHttpError( HttpCode.BAD_REQUEST, "Authentication failed. This could happen if your security key wasn't recognized or was removed too early. Please ensure your security key is properly connected and try again." ) ); } // Update sign count await db .update(securityKeys) .set({ signCount: authenticationInfo.newCounter, lastUsed: new Date().toISOString() }) .where(eq(securityKeys.credentialId, credentialId)); // Create session for the user const { createSession, generateSessionToken, serializeSessionCookie } = await import("@server/auth/sessions/app"); const token = generateSessionToken(); const session = await createSession(token, user.userId); const isSecure = req.protocol === "https"; const cookie = serializeSessionCookie( token, isSecure, new Date(session.expiresAt) ); res.setHeader("Set-Cookie", cookie); // Clear challenge data await clearChallenge(tempSessionId); return response(res, { data: null, success: true, error: false, message: "Authentication successful", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to verify authentication" ) ); } } ================================================ FILE: server/routers/auth/setServerAdmin.ts ================================================ import { NextFunction, Request, Response } from "express"; import HttpCode from "@server/types/HttpCode"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import createHttpError from "http-errors"; import { generateId } from "@server/auth/sessions/app"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import { passwordSchema } from "@server/auth/passwordSchema"; import { response } from "@server/lib/response"; import { db, users, setupTokens } from "@server/db"; import { eq, and } from "drizzle-orm"; import { UserType } from "@server/types/UserTypes"; import moment from "moment"; export const bodySchema = z.object({ email: z.email().toLowerCase(), password: passwordSchema, setupToken: z.string().min(1, "Setup token is required") }); export type SetServerAdminBody = z.infer; export type SetServerAdminResponse = null; export async function setServerAdmin( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { email, password, setupToken } = parsedBody.data; // Validate setup token const [validToken] = await db .select() .from(setupTokens) .where( and( eq(setupTokens.token, setupToken), eq(setupTokens.used, false) ) ); if (!validToken) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid or expired setup token" ) ); } const [existing] = await db .select() .from(users) .where(eq(users.serverAdmin, true)); if (existing) { return next( createHttpError( HttpCode.BAD_REQUEST, "Server admin already exists" ) ); } const passwordHash = await hashPassword(password); const userId = generateId(15); await db.transaction(async (trx) => { // Mark the token as used await trx .update(setupTokens) .set({ used: true, dateUsed: moment().toISOString() }) .where(eq(setupTokens.tokenId, validToken.tokenId)); // Create the server admin user await trx.insert(users).values({ userId: userId, email: email, type: UserType.Internal, username: email, passwordHash, dateCreated: moment().toISOString(), serverAdmin: true, emailVerified: true, lastPasswordChange: new Date().getTime() }); }); return response(res, { data: null, success: true, error: false, message: "Server admin set successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to set server admin" ) ); } } ================================================ FILE: server/routers/auth/signup.ts ================================================ import { NextFunction, Request, Response } from "express"; import { bannedEmails, bannedIps, db, users } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import { email, z } from "zod"; import { fromError } from "zod-validation-error"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { SqliteError } from "better-sqlite3"; import { sendEmailVerificationCode } from "../../auth/sendEmailVerificationCode"; import { eq, and } from "drizzle-orm"; import moment from "moment"; import { createSession, generateId, generateSessionToken, serializeSessionCookie } from "@server/auth/sessions/app"; import config from "@server/lib/config"; import logger from "@server/logger"; 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 { build } from "@server/build"; export const signupBodySchema = z.object({ email: z.email().toLowerCase(), password: passwordSchema, inviteToken: z.string().optional(), inviteId: z.string().optional(), termsAcceptedTimestamp: z.string().nullable().optional(), marketingEmailConsent: z.boolean().optional(), skipVerificationEmail: z.boolean().optional() }); export type SignUpBody = z.infer; export type SignUpResponse = { emailVerificationRequired?: boolean; }; export async function signup( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = signupBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { email, password, inviteToken, inviteId, termsAcceptedTimestamp, marketingEmailConsent, skipVerificationEmail } = parsedBody.data; const [bannedEmail] = await db .select() .from(bannedEmails) .where(eq(bannedEmails.email, email)) .limit(1); if (bannedEmail) { return next( createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.") ); } if (req.ip) { const [bannedIp] = await db .select() .from(bannedIps) .where(eq(bannedIps.ip, req.ip)) .limit(1); if (bannedIp) { return next( createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.") ); } } const passwordHash = await hashPassword(password); const userId = generateId(15); if (config.getRawConfig().flags?.disable_signup_without_invite) { if (!inviteToken || !inviteId) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Signup blocked without invite. Email: ${email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.BAD_REQUEST, "Signups are disabled without an invite code" ) ); } const { error, existingInvite } = await checkValidInvite({ token: inviteToken, inviteId }); if (error) { return next(createHttpError(HttpCode.BAD_REQUEST, error)); } if (!existingInvite) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invite does not exist") ); } if (existingInvite.email !== email) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `User attempted to use an invite for another user. Email: ${email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.BAD_REQUEST, "Invite is not for this user" ) ); } } try { const existing = await db .select() .from(users) .where( and(eq(users.email, email), eq(users.type, UserType.Internal)) ); if (existing && existing.length > 0) { if (!config.getRawConfig().flags?.require_email_verification) { return next( createHttpError( HttpCode.BAD_REQUEST, "A user with that email address already exists" ) ); } const user = existing[0]; // If the user is already verified, we don't want to create a new user if (user.emailVerified) { return next( createHttpError( HttpCode.BAD_REQUEST, "A user with that email address already exists" ) ); } const dateCreated = moment(user.dateCreated); const now = moment(); const diff = now.diff(dateCreated, "hours"); if (diff < 2) { // If the user was created less than 2 hours ago, we don't want to create a new user return next( createHttpError( HttpCode.BAD_REQUEST, "A user with that email address already exists" ) ); // return response(res, { // data: { // emailVerificationRequired: true // }, // success: true, // error: false, // message: `A user with that email address already exists. We sent an email to ${email} with a verification code.`, // status: HttpCode.OK // }); } else { // If the user was created more than 2 hours ago, we want to delete the old user and create a new one await db.delete(users).where(eq(users.userId, user.userId)); } } if (build === "saas" && !termsAcceptedTimestamp) { return next( createHttpError( HttpCode.BAD_REQUEST, "You must accept the terms of service and privacy policy" ) ); } await db.insert(users).values({ userId: userId, type: UserType.Internal, username: email, email: email, passwordHash, dateCreated: moment().toISOString(), termsAcceptedTimestamp: termsAcceptedTimestamp || null, termsVersion: "1", marketingEmailConsent: marketingEmailConsent ?? false, lastPasswordChange: new Date().getTime() }); // give the user their default permissions: // await db.insert(userActions).values({ // userId: userId, // actionId: ActionsEnum.createOrg, // orgId: null, // }); const token = generateSessionToken(); const sess = await createSession(token, userId); const isSecure = req.protocol === "https"; const cookie = serializeSessionCookie( token, isSecure, new Date(sess.expiresAt) ); res.appendHeader("Set-Cookie", cookie); if (build == "saas" && marketingEmailConsent) { logger.debug( `User ${email} opted in to marketing emails during signup.` ); // TODO: update user in Sendy } if (config.getRawConfig().flags?.require_email_verification) { if (!skipVerificationEmail) { sendEmailVerificationCode(email, userId); } else { logger.debug( `User ${email} opted out of verification email during signup.` ); } return response(res, { data: { emailVerificationRequired: true }, success: true, error: false, 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 }); } return response(res, { data: {}, success: true, error: false, message: "User created successfully", status: HttpCode.OK }); } catch (e) { if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Account already exists with that email. Email: ${email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.BAD_REQUEST, "A user with that email address already exists" ) ); } else { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create user" ) ); } } } ================================================ FILE: server/routers/auth/startDeviceWebAuth.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { response } from "@server/lib/response"; import { db, deviceWebAuthCodes } from "@server/db"; import { alphabet, generateRandomString } from "oslo/crypto"; import { createDate } from "oslo"; import { TimeSpan } from "oslo"; import { maxmindLookup } from "@server/db/maxmind"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { stripPortFromHost } from "@server/lib/ip"; const bodySchema = z .object({ deviceName: z.string().optional(), applicationName: z.string().min(1, "Application name is required") }) .strict(); export type StartDeviceWebAuthBody = z.infer; export type StartDeviceWebAuthResponse = { code: string; expiresInSeconds: number; }; // Helper function to generate device code in format A1AJ-N5JD function generateDeviceCode(): string { const part1 = generateRandomString(4, alphabet("A-Z", "0-9")); const part2 = generateRandomString(4, alphabet("A-Z", "0-9")); return `${part1}-${part2}`; } // Helper function to hash device code before storing in database function hashDeviceCode(code: string): string { return encodeHexLowerCase(sha256(new TextEncoder().encode(code))); } // Helper function to get city from IP (if available) async function getCityFromIp(ip: string): Promise { try { if (!maxmindLookup) { return undefined; } const result = maxmindLookup.get(ip); if (!result) { return undefined; } if (result.country) { return result.country.names?.en || result.country.iso_code; } return undefined; } catch (error) { logger.debug("Failed to get city from IP", error); return undefined; } } export async function startDeviceWebAuth( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } try { const { deviceName, applicationName } = parsedBody.data; // Generate device code const code = generateDeviceCode(); // Hash the code before storing in database const hashedCode = hashDeviceCode(code); // Extract IP from request const ip = req.ip ? stripPortFromHost(req.ip) : undefined; // Get city (optional, may return undefined) const city = ip ? await getCityFromIp(ip) : undefined; // Set expiration to 5 minutes from now const expiresAt = createDate(new TimeSpan(5, "m")).getTime(); // Insert into database (store hashed code) await db.insert(deviceWebAuthCodes).values({ code: hashedCode, ip: ip || null, city: city || null, deviceName: deviceName || null, applicationName, expiresAt, createdAt: Date.now() }); // calculate relative expiration in seconds const expiresInSeconds = Math.floor((expiresAt - Date.now()) / 1000); return response(res, { data: { code, expiresInSeconds }, success: true, error: false, message: "Device web auth code generated", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to start device web auth" ) ); } } ================================================ FILE: server/routers/auth/types.ts ================================================ export type TransferSessionResponse = { valid: boolean; cookie?: string; }; export type GetSessionTransferTokenRenponse = { token: string; }; ================================================ FILE: server/routers/auth/validateSetupToken.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, setupTokens } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const validateSetupTokenSchema = z.strictObject({ token: z.string().min(1, "Token is required") }); export type ValidateSetupTokenResponse = { valid: boolean; message: string; }; export async function validateSetupToken( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = validateSetupTokenSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { token } = parsedBody.data; // Find the token in the database const [setupToken] = await db .select() .from(setupTokens) .where( and(eq(setupTokens.token, token), eq(setupTokens.used, false)) ); if (!setupToken) { return response(res, { data: { valid: false, message: "Invalid or expired setup token" }, success: true, error: false, message: "Token validation completed", status: HttpCode.OK }); } return response(res, { data: { valid: true, message: "Setup token is valid" }, success: true, error: false, message: "Token validation completed", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to validate setup token" ) ); } } ================================================ FILE: server/routers/auth/verifyDeviceWebAuth.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { response } from "@server/lib/response"; import { db, deviceWebAuthCodes, sessions } from "@server/db"; import { eq, and, gt } from "drizzle-orm"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { unauthorized } from "@server/auth/unauthorizedResponse"; import { getIosDeviceName, getMacDeviceName } from "@server/db/names"; const bodySchema = z .object({ code: z.string().min(1, "Code is required"), verify: z.boolean().optional().default(false) // If false, just check and return metadata }) .strict(); // Helper function to hash device code before querying database function hashDeviceCode(code: string): string { return encodeHexLowerCase(sha256(new TextEncoder().encode(code))); } export type VerifyDeviceWebAuthBody = z.infer; export type VerifyDeviceWebAuthResponse = { success: boolean; message: string; metadata?: { ip: string | null; city: string | null; deviceName: string | null; applicationName: string; createdAt: number; }; }; export async function verifyDeviceWebAuth( req: Request, res: Response, next: NextFunction ): Promise { const { user, session } = req; if (!user || !session) { return next(createHttpError(HttpCode.UNAUTHORIZED, "Unauthorized")); } if (session.deviceAuthUsed) { return next( createHttpError( HttpCode.UNAUTHORIZED, "Device web auth code already used for this session" ) ); } if (!session.issuedAt) { return next( createHttpError( HttpCode.UNAUTHORIZED, "Session issuedAt timestamp missing" ) ); } // make sure sessions is not older than 5 minutes const now = Date.now(); if (now - session.issuedAt > 5 * 60 * 1000) { return next( createHttpError( HttpCode.UNAUTHORIZED, "Session is too old to verify device web auth code" ) ); } const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } try { const { code, verify } = parsedBody.data; const now = Date.now(); logger.debug("Verifying device web auth code:", { code }); // Hash the code before querying const hashedCode = hashDeviceCode(code); // Find the code in the database that is not expired and not already verified const [deviceCode] = await db .select() .from(deviceWebAuthCodes) .where( and( eq(deviceWebAuthCodes.code, hashedCode), gt(deviceWebAuthCodes.expiresAt, now), eq(deviceWebAuthCodes.verified, false) ) ) .limit(1); logger.debug("Device code lookup result:", deviceCode); if (!deviceCode) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid, expired, or already verified code" ) ); } const deviceName = getMacDeviceName(deviceCode.deviceName) || getIosDeviceName(deviceCode.deviceName) || deviceCode.deviceName; // If verify is false, just return metadata without verifying if (!verify) { return response(res, { data: { success: true, message: "Code is valid", metadata: { ip: deviceCode.ip, city: deviceCode.city, deviceName: deviceName, applicationName: deviceCode.applicationName, createdAt: deviceCode.createdAt } }, success: true, error: false, message: "Code validation successful", status: HttpCode.OK }); } // Update the code to mark it as verified and store the user who verified it await db .update(deviceWebAuthCodes) .set({ verified: true, userId: req.user!.userId }) .where(eq(deviceWebAuthCodes.codeId, deviceCode.codeId)); // Also update the session to mark that device auth was used await db .update(sessions) .set({ deviceAuthUsed: true }) .where(eq(sessions.sessionId, session.sessionId)); return response(res, { data: { success: true, message: "Device code verified successfully" }, success: true, error: false, message: "Device code verified successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to verify device code" ) ); } } ================================================ FILE: server/routers/auth/verifyEmail.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib/response"; import { db, userOrgs } from "@server/db"; import { User, emailVerificationCodes, users } from "@server/db"; import { eq } from "drizzle-orm"; import { isWithinExpirationDate } from "oslo"; import config from "@server/lib/config"; import logger from "@server/logger"; import { freeLimitSet, limitsService } from "@server/lib/billing"; import { build } from "@server/build"; export const verifyEmailBody = z.strictObject({ code: z.string() }); export type VerifyEmailBody = z.infer; export type VerifyEmailResponse = { valid: boolean; }; export async function verifyEmail( req: Request, res: Response, next: NextFunction ): Promise { if (!config.getRawConfig().flags?.require_email_verification) { return next( createHttpError( HttpCode.BAD_REQUEST, "Email verification is not enabled" ) ); } const parsedBody = verifyEmailBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { code } = parsedBody.data; const user = req.user as User; if (user.emailVerified) { return next( createHttpError(HttpCode.BAD_REQUEST, "Email is already verified") ); } try { const valid = await isValidCode(user, code); if (valid) { await db.transaction(async (trx) => { await trx .delete(emailVerificationCodes) .where(eq(emailVerificationCodes.userId, user.userId)); await trx .update(users) .set({ emailVerified: true }) .where(eq(users.userId, user.userId)); }); } else { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Email verification code incorrect. Email: ${user.email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid verification code" ) ); } if (build == "saas") { const orgs = await db .select() .from(userOrgs) .where(eq(userOrgs.userId, user.userId)); const orgIds = orgs.map((org) => org.orgId); await Promise.all( orgIds.map(async (orgId) => { await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); }) ); } return response(res, { success: true, error: false, message: "Email verified", status: HttpCode.OK, data: { valid } }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to verify email" ) ); } } export default verifyEmail; async function isValidCode(user: User, code: string): Promise { const codeRecord = await db .select() .from(emailVerificationCodes) .where(eq(emailVerificationCodes.userId, user.userId)) .limit(1); if (user.email !== codeRecord[0].email) { return false; } if (codeRecord.length === 0) { return false; } if (codeRecord[0].code !== code) { return false; } if (!isWithinExpirationDate(new Date(codeRecord[0].expiresAt))) { return false; } return true; } ================================================ FILE: server/routers/auth/verifyTotp.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib/response"; import { db } from "@server/db"; import { twoFactorBackupCodes, User, users } from "@server/db"; import { eq, and } from "drizzle-orm"; import { hashPassword, verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import logger from "@server/logger"; import { sendEmail } from "@server/emails"; import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNotification"; import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; import { generateBackupCodes } from "@server/lib/totp"; import { verifySession } from "@server/auth/sessions/verifySession"; import { unauthorized } from "@server/auth/unauthorizedResponse"; export const verifyTotpBody = z.strictObject({ email: z.email().optional(), password: z.string().optional(), code: z.string() }); export type VerifyTotpBody = z.infer; export type VerifyTotpResponse = { valid: boolean; backupCodes?: string[]; }; export async function verifyTotp( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = verifyTotpBody.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { code, email, password } = parsedBody.data; try { const { user: sessionUser, session: existingSession } = await verifySession(req); let user: User | null = sessionUser; if (!existingSession) { if (!email || !password) { return next( createHttpError( HttpCode.BAD_REQUEST, "Email and password are required for two-factor authentication" ) ); } const [res] = await db .select() .from(users) .where( and( eq(users.type, UserType.Internal), eq(users.email, email) ) ); user = res; const validPassword = await verifyPassword( password, user.passwordHash! ); if (!validPassword) { return next(unauthorized()); } } if (!user) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.UNAUTHORIZED, "Username or password is incorrect" ) ); } if (user.type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Two-factor authentication is not supported for external users" ) ); } if (user.twoFactorEnabled) { return next( createHttpError( HttpCode.BAD_REQUEST, "Two-factor authentication is already enabled" ) ); } if (!user.twoFactorSecret) { return next( createHttpError( HttpCode.BAD_REQUEST, "User has not requested two-factor authentication" ) ); } const valid = await verifyTotpCode( code, user.twoFactorSecret, user.userId ); let codes; if (valid) { // if valid, enable two-factor authentication; the totp secret is no longer temporary await db.transaction(async (trx) => { await trx .update(users) .set({ twoFactorEnabled: true }) .where(eq(users.userId, user.userId)); const backupCodes = await generateBackupCodes(); codes = backupCodes; for (const code of backupCodes) { const hash = await hashPassword(code); await trx.insert(twoFactorBackupCodes).values({ userId: user.userId, codeHash: hash }); } }); } if (!valid) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.` ); } return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid two-factor authentication code" ) ); } sendEmail( TwoFactorAuthNotification({ email: user.email!, enabled: true }), { to: user.email!, from: config.getRawConfig().email?.no_reply, subject: "Two-factor authentication enabled" } ); return response(res, { data: { valid, ...(valid && codes ? { backupCodes: codes } : {}) }, success: true, error: false, message: valid ? "Code is valid. Two-factor is now enabled" : "Code is invalid", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to verify two-factor authentication code" ) ); } } ================================================ FILE: server/routers/badger/exchangeSession.ts ================================================ import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { resourceAccessToken, resources, sessions } from "@server/db"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; import { createResourceSession, serializeResourceSessionCookie, validateResourceSessionToken } from "@server/auth/sessions/resource"; import { generateSessionToken, SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app"; import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource"; import config from "@server/lib/config"; import { response } from "@server/lib/response"; import { stripPortFromHost } from "@server/lib/ip"; const exchangeSessionBodySchema = z.object({ requestToken: z.string(), host: z.string(), requestIp: z.string().optional() }); export type ExchangeSessionBodySchema = z.infer< typeof exchangeSessionBodySchema >; export type ExchangeSessionResponse = { valid: boolean; cookie?: string; }; export async function exchangeSession( req: Request, res: Response, next: NextFunction ): Promise { logger.debug("Exchange session: Badger sent", req.body); const parsedBody = exchangeSessionBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } try { const { requestToken, host, requestIp } = parsedBody.data; let cleanHost = host; // if the host ends with :port if (cleanHost.match(/:[0-9]{1,5}$/)) { const matched = "" + cleanHost.match(/:[0-9]{1,5}$/); cleanHost = cleanHost.slice(0, -1 * matched.length); } const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined; const [resource] = await db .select() .from(resources) .where(eq(resources.fullDomain, cleanHost)) .limit(1); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with host ${cleanHost} not found` ) ); } const { resourceSession: requestSession } = await validateResourceSessionToken( requestToken, resource.resourceId ); if (!requestSession) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` ); } return next( createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token") ); } if (!requestSession.isRequestToken) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.` ); } return next( createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token") ); } await db.delete(sessions).where(eq(sessions.sessionId, requestToken)); const token = generateSessionToken(); let expiresAt: number | null = null; if (requestSession.userSessionId) { const [res] = await db .select() .from(sessions) .where(eq(sessions.sessionId, requestSession.userSessionId)) .limit(1); if (res) { await createResourceSession({ token, resourceId: resource.resourceId, isRequestToken: false, userSessionId: requestSession.userSessionId, doNotExtend: false, expiresAt: res.expiresAt, sessionLength: SESSION_COOKIE_EXPIRES }); expiresAt = res.expiresAt; } } else if (requestSession.accessTokenId) { const [res] = await db .select() .from(resourceAccessToken) .where( eq( resourceAccessToken.accessTokenId, requestSession.accessTokenId ) ) .limit(1); if (res) { await createResourceSession({ token, resourceId: resource.resourceId, isRequestToken: false, accessTokenId: requestSession.accessTokenId, doNotExtend: true, expiresAt: res.expiresAt, sessionLength: res.sessionLength }); expiresAt = res.expiresAt; } } else { const expires = new Date( Date.now() + SESSION_COOKIE_EXPIRES ).getTime(); await createResourceSession({ token, resourceId: resource.resourceId, isRequestToken: false, passwordId: requestSession.passwordId, pincodeId: requestSession.pincodeId, userSessionId: requestSession.userSessionId, whitelistId: requestSession.whitelistId, accessTokenId: requestSession.accessTokenId, doNotExtend: false, expiresAt: expires, sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES }); expiresAt = expires; } const cookieName = `${config.getRawConfig().server.session_cookie_name}`; const cookie = serializeResourceSessionCookie( cookieName, resource.fullDomain!, token, !resource.ssl, expiresAt ? new Date(expiresAt) : undefined ); logger.debug(JSON.stringify("Exchange cookie: " + cookie)); return response(res, { data: { valid: true, cookie }, success: true, error: false, message: "Session exchanged successfully", status: HttpCode.OK }); } catch (e) { console.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to exchange session" ) ); } } ================================================ FILE: server/routers/badger/index.ts ================================================ export * from "./verifySession"; export * from "./exchangeSession"; ================================================ FILE: server/routers/badger/logRequestAudit.ts ================================================ import { logsDb, primaryLogsDb, db, orgs, requestAuditLog } from "@server/db"; import logger from "@server/logger"; import { and, eq, lt, sql } from "drizzle-orm"; import cache from "#dynamic/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { stripPortFromHost } from "@server/lib/ip"; /** Reasons: 100 - Allowed by Rule 101 - Allowed No Auth 102 - Valid Access Token 103 - Valid Header Auth (HTTP Basic Auth) 104 - Valid Pincode 105 - Valid Password 106 - Valid email 107 - Valid SSO 201 - Resource Not Found 202 - Resource Blocked 203 - Dropped by Rule 204 - No Sessions 205 - Temporary Request Token 299 - No More Auth Methods */ // In-memory buffer for batching audit logs const auditLogBuffer: Array<{ timestamp: number; orgId?: string; actorType?: string; actor?: string; actorId?: string; metadata: any; action: boolean; resourceId?: number; reason: number; location?: string; originalRequestURL: string; scheme: string; host: string; path: string; method: string; ip?: string; tls: boolean; }> = []; const BATCH_SIZE = 100; // Write to DB every 100 logs const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth let flushTimer: NodeJS.Timeout | null = null; let isFlushInProgress = false; /** * Flush buffered logs to database */ async function flushAuditLogs() { if (auditLogBuffer.length === 0 || isFlushInProgress) { return; } isFlushInProgress = true; // Take all current logs and clear buffer const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length); try { // Use a transaction to ensure all inserts succeed or fail together // This prevents index corruption from partial writes await logsDb.transaction(async (tx) => { // Batch insert logs in groups of 25 to avoid overwhelming the database const BATCH_DB_SIZE = 25; for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); await tx.insert(requestAuditLog).values(batch); } }); logger.debug(`Flushed ${logsToWrite.length} audit logs to database`); } catch (error) { logger.error("Error flushing audit logs:", error); // On transaction error, put logs back at the front of the buffer to retry // but only if buffer isn't too large if (auditLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) { auditLogBuffer.unshift(...logsToWrite); logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`); } else { logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`); } } finally { isFlushInProgress = false; // If buffer filled up while we were flushing, flush again if (auditLogBuffer.length >= BATCH_SIZE) { flushAuditLogs().catch((err) => logger.error("Error in follow-up flush:", err) ); } } } /** * Schedule a flush if not already scheduled */ function scheduleFlush() { if (flushTimer === null) { flushTimer = setTimeout(() => { flushTimer = null; flushAuditLogs().catch((err) => logger.error("Error in scheduled flush:", err) ); }, BATCH_INTERVAL_MS); } } /** * Gracefully flush all pending logs (call this on shutdown) */ export async function shutdownAuditLogger() { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } // Force flush even if one is in progress by waiting and retrying while (isFlushInProgress) { await new Promise((resolve) => setTimeout(resolve, 100)); } await flushAuditLogs(); } async function getRetentionDays(orgId: string): Promise { // check cache first const cached = await cache.get(`org_${orgId}_retentionDays`); if (cached !== undefined) { return cached; } const [org] = await db .select({ settingsLogRetentionDaysRequest: orgs.settingsLogRetentionDaysRequest }) .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (!org) { return 0; } // store the result in cache await cache.set( `org_${orgId}_retentionDays`, org.settingsLogRetentionDaysRequest, 300 ); return org.settingsLogRetentionDaysRequest; } export async function cleanUpOldLogs(orgId: string, retentionDays: number) { const cutoffTimestamp = calculateCutoffTimestamp(retentionDays); try { await logsDb .delete(requestAuditLog) .where( and( lt(requestAuditLog.timestamp, cutoffTimestamp), eq(requestAuditLog.orgId, orgId) ) ); // logger.debug( // `Cleaned up request audit logs older than ${retentionDays} days` // ); } catch (error) { logger.error("Error cleaning up old request audit logs:", error); } } export async function logRequestAudit( data: { action: boolean; reason: number; resourceId?: number; orgId?: string; location?: string; user?: { username: string; userId: string }; apiKey?: { name: string | null; apiKeyId: string }; metadata?: any; // userAgent?: string; }, body: { path: string; originalRequestURL: string; scheme: string; host: string; method: string; tls: boolean; sessions?: Record; headers?: Record; query?: Record; requestIp?: string; } ) { try { // Check retention before buffering any logs if (data.orgId) { const retentionDays = await getRetentionDays(data.orgId); if (retentionDays === 0) { // do not log return; } } let actorType: string | undefined; let actor: string | undefined; let actorId: string | undefined; const user = data.user; if (user) { actorType = "user"; actor = user.username; actorId = user.userId; } const apiKey = data.apiKey; if (apiKey) { actorType = "apiKey"; actor = apiKey.name || apiKey.apiKeyId; actorId = apiKey.apiKeyId; } const timestamp = Math.floor(Date.now() / 1000); let metadata = null; if (data.metadata) { metadata = JSON.stringify(data.metadata); } const clientIp = body.requestIp ? stripPortFromHost(body.requestIp) : undefined; // Prevent unbounded buffer growth - drop oldest entries if buffer is too large if (auditLogBuffer.length >= MAX_BUFFER_SIZE) { const dropped = auditLogBuffer.splice(0, BATCH_SIZE); logger.warn( `Audit log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries` ); } // Add to buffer instead of writing directly to DB auditLogBuffer.push({ timestamp, orgId: data.orgId, actorType, actor, actorId, metadata, action: data.action, resourceId: data.resourceId, reason: data.reason, location: data.location, originalRequestURL: body.originalRequestURL, scheme: body.scheme, host: body.host, path: body.path, method: body.method, ip: clientIp, tls: body.tls }); // Flush immediately if buffer is full, otherwise schedule a flush if (auditLogBuffer.length >= BATCH_SIZE) { // Fire and forget - don't block the caller flushAuditLogs().catch((err) => logger.error("Error flushing audit logs:", err) ); } else { scheduleFlush(); } } catch (error) { logger.error(error); } } ================================================ FILE: server/routers/badger/verifySession.test.ts ================================================ import { assertEquals } from "@test/assert"; function isPathAllowed(pattern: string, path: string): boolean { // Normalize and split paths into segments const normalize = (p: string) => p.split("/").filter(Boolean); const patternParts = normalize(pattern); const pathParts = normalize(path); // Recursive function to try different wildcard matches function matchSegments(patternIndex: number, pathIndex: number): boolean { const indent = " ".repeat(pathIndex); // Indent based on recursion depth const currentPatternPart = patternParts[patternIndex]; const currentPathPart = pathParts[pathIndex]; // If we've consumed all pattern parts, we should have consumed all path parts if (patternIndex >= patternParts.length) { const result = pathIndex >= pathParts.length; return result; } // If we've consumed all path parts but still have pattern parts if (pathIndex >= pathParts.length) { // The only way this can match is if all remaining pattern parts are wildcards const remainingPattern = patternParts.slice(patternIndex); const result = remainingPattern.every((p) => p === "*"); return result; } // For full segment wildcards, try consuming different numbers of path segments if (currentPatternPart === "*") { // Try consuming 0 segments (skip the wildcard) if (matchSegments(patternIndex + 1, pathIndex)) { return true; } // Try consuming current segment and recursively try rest if (matchSegments(patternIndex, pathIndex + 1)) { return true; } return false; } // Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix") if (currentPatternPart.includes("*")) { // Convert the pattern segment to a regex pattern const regexPattern = currentPatternPart .replace(/\*/g, ".*") // Replace * with .* for regex wildcard .replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed const regex = new RegExp(`^${regexPattern}$`); if (regex.test(currentPathPart)) { return matchSegments(patternIndex + 1, pathIndex + 1); } return false; } // For regular segments, they must match exactly if (currentPatternPart !== currentPathPart) { return false; } // Move to next segments in both pattern and path return matchSegments(patternIndex + 1, pathIndex + 1); } const result = matchSegments(0, 0); return result; } function runTests() { console.log("Running path matching tests..."); // Test exact matching assertEquals( isPathAllowed("foo", "foo"), true, "Exact match should be allowed" ); assertEquals( isPathAllowed("foo", "bar"), false, "Different segments should not match" ); assertEquals( isPathAllowed("foo/bar", "foo/bar"), true, "Exact multi-segment match should be allowed" ); assertEquals( isPathAllowed("foo/bar", "foo/baz"), false, "Partial multi-segment match should not be allowed" ); // Test with leading and trailing slashes assertEquals( isPathAllowed("/foo", "foo"), true, "Pattern with leading slash should match" ); assertEquals( isPathAllowed("foo/", "foo"), true, "Pattern with trailing slash should match" ); assertEquals( isPathAllowed("/foo/", "foo"), true, "Pattern with both leading and trailing slashes should match" ); assertEquals( isPathAllowed("foo", "/foo/"), true, "Path with leading and trailing slashes should match" ); // Test simple wildcard matching assertEquals( isPathAllowed("*", "foo"), true, "Single wildcard should match any single segment" ); assertEquals( isPathAllowed("*", "foo/bar"), true, "Single wildcard should match multiple segments" ); assertEquals( isPathAllowed("*/bar", "foo/bar"), true, "Wildcard prefix should match" ); assertEquals( isPathAllowed("foo/*", "foo/bar"), true, "Wildcard suffix should match" ); assertEquals( isPathAllowed("foo/*/baz", "foo/bar/baz"), true, "Wildcard in middle should match" ); // Test multiple wildcards assertEquals( isPathAllowed("*/*", "foo/bar"), true, "Multiple wildcards should match corresponding segments" ); assertEquals( isPathAllowed("*/*/*", "foo/bar/baz"), true, "Three wildcards should match three segments" ); assertEquals( isPathAllowed("foo/*/*", "foo/bar/baz"), true, "Specific prefix with wildcards should match" ); assertEquals( isPathAllowed("*/*/baz", "foo/bar/baz"), true, "Wildcards with specific suffix should match" ); // Test wildcard consumption behavior assertEquals( isPathAllowed("*", ""), true, "Wildcard should optionally consume segments" ); assertEquals( isPathAllowed("foo/*", "foo"), true, "Trailing wildcard should be optional" ); assertEquals( isPathAllowed("*/*", "foo"), true, "Multiple wildcards can match fewer segments" ); assertEquals( isPathAllowed("*/*/*", "foo/bar"), true, "Extra wildcards can be skipped" ); // Test complex nested paths assertEquals( isPathAllowed("api/*/users", "api/v1/users"), true, "API versioning pattern should match" ); assertEquals( isPathAllowed("api/*/users/*", "api/v1/users/123"), true, "API resource pattern should match" ); assertEquals( isPathAllowed("api/*/users/*/profile", "api/v1/users/123/profile"), true, "Nested API pattern should match" ); // Test for the requested padbootstrap* pattern assertEquals( isPathAllowed("padbootstrap*", "padbootstrap"), true, "padbootstrap* should match padbootstrap" ); assertEquals( isPathAllowed("padbootstrap*", "padbootstrapv1"), true, "padbootstrap* should match padbootstrapv1" ); assertEquals( isPathAllowed("padbootstrap*", "padbootstrap/files"), false, "padbootstrap* should not match padbootstrap/files" ); assertEquals( isPathAllowed("padbootstrap*/*", "padbootstrap/files"), true, "padbootstrap*/* should match padbootstrap/files" ); assertEquals( isPathAllowed("padbootstrap*/files", "padbootstrapv1/files"), true, "padbootstrap*/files should not match padbootstrapv1/files (wildcard is segment-based, not partial)" ); // Test wildcard edge cases assertEquals( isPathAllowed("*/*/*/*/*/*", "a/b"), true, "Many wildcards can match few segments" ); assertEquals( isPathAllowed("a/*/b/*/c", "a/anything/b/something/c"), true, "Multiple wildcards in pattern should match corresponding segments" ); // Test patterns with partial segment matches assertEquals( isPathAllowed("padbootstrap*", "padbootstrap-123"), true, "Wildcards in isPathAllowed should be segment-based, not character-based" ); assertEquals( isPathAllowed("test*", "testuser"), true, "Asterisk as part of segment name is treated as a literal, not a wildcard" ); assertEquals( isPathAllowed("my*app", "myapp"), true, "Asterisk in middle of segment name is treated as a literal, not a wildcard" ); assertEquals( isPathAllowed("/", "/"), true, "Root path should match root path" ); assertEquals( isPathAllowed("/", "/test"), false, "Root path should not match non-root path" ); console.log("All tests passed!"); } // Run all tests try { runTests(); } catch (error) { console.error("Test failed:", error); } ================================================ FILE: server/routers/badger/verifySession.ts ================================================ import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { getResourceByDomain, getResourceRules, getRoleResourceAccess, getUserOrgRole, getUserResourceAccess, getOrgLoginPage, getUserSessionWithUser } from "@server/db/queries/verifySessionQueries"; import { LoginPage, Org, Resource, ResourceHeaderAuth, ResourceHeaderAuthExtendedCompatibility, ResourcePassword, ResourcePincode, ResourceRule } from "@server/db"; import config from "@server/lib/config"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { response } from "@server/lib/response"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; import { getAsnForIp } from "@server/lib/asn"; import { getOrgTierData } from "#dynamic/lib/billing"; import { verifyPassword } from "@server/auth/password"; import { checkOrgAccessPolicy, enforceResourceSessionLength } from "#dynamic/lib/checkOrgAccessPolicy"; import { logRequestAudit } from "./logRequestAudit"; import { localCache } from "#dynamic/lib/cache"; import { APP_VERSION } from "@server/lib/consts"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string(), z.string()).optional(), headers: z.record(z.string(), z.string()).optional(), query: z.record(z.string(), z.string()).optional(), originalRequestURL: z.url(), scheme: z.string(), host: z.string(), path: z.string(), method: z.string(), tls: z.boolean(), requestIp: z.string().optional(), badgerVersion: z.string().optional() }); export type VerifyResourceSessionSchema = z.infer< typeof verifyResourceSessionSchema >; type BasicUserData = { username: string; email: string | null; name: string | null; role: string | null; }; export type VerifyUserResponse = { valid: boolean; headerAuthChallenged?: boolean; redirectUrl?: string; userData?: BasicUserData; pangolinVersion?: string; }; export async function verifyResourceSession( req: Request, res: Response, next: NextFunction ): Promise { logger.debug("Verify session: Badger sent", req.body); // remove when done testing const parsedBody = verifyResourceSessionSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } try { const { sessions, host, originalRequestURL, requestIp, path, headers, query, badgerVersion } = parsedBody.data; // Extract HTTP Basic Auth credentials if present const clientHeaderAuth = extractBasicAuth(headers); const clientIp = requestIp ? stripPortFromHost(requestIp, badgerVersion) : undefined; logger.debug("Client IP:", { clientIp }); const ipCC = clientIp ? await getCountryCodeFromIp(clientIp) : undefined; const ipAsn = clientIp ? await getAsnFromIp(clientIp) : undefined; let cleanHost = host; // if the host ends with :port, strip it if (cleanHost.match(/:[0-9]{1,5}$/)) { const matched = "" + cleanHost.match(/:[0-9]{1,5}$/); cleanHost = cleanHost.slice(0, -1 * matched.length); } const resourceCacheKey = `resource:${cleanHost}`; let resourceData: | { resource: Resource | null; pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; org: Org; } | undefined = localCache.get(resourceCacheKey); if (!resourceData) { const result = await getResourceByDomain(cleanHost); if (!result) { logger.debug(`Resource not found ${cleanHost}`); // TODO: we cant log this for now because we dont know the org // eventually it would be cool to show this for the server admin // logRequestAudit( // { // action: false, // reason: 201, //resource not found // location: ipCC // }, // parsedBody.data // ); return notAllowed(res); } resourceData = result; localCache.set(resourceCacheKey, resourceData, 5); } const { resource, pincode, password, headerAuth, headerAuthExtendedCompatibility } = resourceData; if (!resource) { logger.debug(`Resource not found ${cleanHost}`); // TODO: we cant log this for now because we dont know the org // eventually it would be cool to show this for the server admin // logRequestAudit( // { // action: false, // reason: 201, //resource not found // location: ipCC // }, // parsedBody.data // ); return notAllowed(res); } const { sso, blockAccess } = resource; if (blockAccess) { logger.debug("Resource blocked", host); logRequestAudit( { action: false, reason: 202, //resource blocked resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return notAllowed(res); } // check the rules if (resource.applyRules) { const action = await checkRules( resource.resourceId, clientIp, path, ipCC, ipAsn ); if (action == "ACCEPT") { logger.debug("Resource allowed by rule"); logRequestAudit( { action: true, reason: 100, // allowed by rule resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return allowed(res); } else if (action == "DROP") { logger.debug("Resource denied by rule"); // TODO: add rules type logRequestAudit( { action: false, reason: 203, // dropped by rules resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return notAllowed(res); } else if (action == "PASS") { logger.debug( "Resource passed by rule, continuing to auth checks" ); // Continue to authentication checks below } // otherwise its undefined and we pass } // IMPORTANT: ADD NEW AUTH CHECKS HERE OR WHEN TURNING OFF ALL OTHER AUTH METHODS IT WILL JUST PASS if ( !sso && !pincode && !password && !resource.emailWhitelistEnabled && !headerAuth ) { logger.debug("Resource allowed because no auth"); logRequestAudit( { action: true, reason: 101, // allowed no auth resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return allowed(res); } const redirectPath = `/auth/resource/${encodeURIComponent( resource.resourceGuid )}?redirect=${encodeURIComponent(originalRequestURL)}`; // check for access token in headers if ( headers && headers[ config.getRawConfig().server.resource_access_token_headers.id ] && headers[ config.getRawConfig().server.resource_access_token_headers.token ] ) { const accessTokenId = headers[ config.getRawConfig().server.resource_access_token_headers .id ]; const accessToken = headers[ config.getRawConfig().server.resource_access_token_headers .token ]; const { valid, error, tokenItem } = await verifyResourceAccessToken( { accessToken, accessTokenId, resourceId: resource.resourceId } ); if (error) { logger.debug("Access token invalid: " + error); } if (!valid) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Resource access token is invalid. Resource ID: ${ resource.resourceId }. IP: ${clientIp}.` ); } } if (valid && tokenItem) { logRequestAudit( { action: true, reason: 102, // valid access token resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC, apiKey: { name: tokenItem.title, apiKeyId: tokenItem.accessTokenId } }, parsedBody.data ); return allowed(res); } } if ( query && query[config.getRawConfig().server.resource_access_token_param] ) { const token = query[config.getRawConfig().server.resource_access_token_param]; const [accessTokenId, accessToken] = token.split("."); const { valid, error, tokenItem } = await verifyResourceAccessToken( { accessToken, accessTokenId, resourceId: resource.resourceId } ); if (error) { logger.debug("Access token invalid: " + error); } if (!valid) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Resource access token is invalid. Resource ID: ${ resource.resourceId }. IP: ${clientIp}.` ); } } if (valid && tokenItem) { logRequestAudit( { action: true, reason: 102, // valid access token resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC, apiKey: { name: tokenItem.title, apiKeyId: tokenItem.accessTokenId } }, parsedBody.data ); return allowed(res); } } // check for HTTP Basic Auth header const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`; if (headerAuth && clientHeaderAuth) { if (localCache.get(clientHeaderAuthKey)) { logger.debug( "Resource allowed because header auth is valid (cached)" ); logRequestAudit( { action: true, reason: 103, // valid header auth resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return allowed(res); } else if ( await verifyPassword( clientHeaderAuth, headerAuth.headerAuthHash ) ) { localCache.set(clientHeaderAuthKey, clientHeaderAuth, 5); logger.debug("Resource allowed because header auth is valid"); logRequestAudit( { action: true, reason: 103, // valid header auth resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return allowed(res); } if ( // we dont want to redirect if this is the only auth method and we did not pass here !sso && !pincode && !password && !resource.emailWhitelistEnabled && !headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated ) { logRequestAudit( { action: false, reason: 299, // no more auth methods resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return notAllowed(res); } } else if (headerAuth) { // if there are no other auth methods we need to return unauthorized if nothing is provided if ( !sso && !pincode && !password && !resource.emailWhitelistEnabled && !headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated ) { logRequestAudit( { action: false, reason: 299, // no more auth methods resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return notAllowed(res); } } if (!sessions) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Missing resource sessions. Resource ID: ${ resource.resourceId }. IP: ${clientIp}.` ); } logRequestAudit( { action: false, reason: 204, // no sessions resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return notAllowed(res); } const resourceSessionToken = extractResourceSessionToken( sessions, resource.ssl ); if (resourceSessionToken) { const sessionCacheKey = `session:${resourceSessionToken}`; let resourceSession: any = localCache.get(sessionCacheKey); if (!resourceSession) { const result = await validateResourceSessionToken( resourceSessionToken, resource.resourceId ); resourceSession = result?.resourceSession; localCache.set(sessionCacheKey, resourceSession, 5); } if (resourceSession?.isRequestToken) { logger.debug( "Resource not allowed because session is a temporary request token" ); if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Resource session is an exchange token. Resource ID: ${ resource.resourceId }. IP: ${clientIp}.` ); } logRequestAudit( { action: false, reason: 205, // temporary request token resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return notAllowed(res); } if (resourceSession) { // only run this check if not SSO session; SSO session length is checked later const accessPolicy = await enforceResourceSessionLength( resourceSession, resourceData.org ); if (!accessPolicy.valid) { logger.debug( "Resource session invalid due to org policy:", accessPolicy.error ); return notAllowed(res, redirectPath, resource.orgId); } if (pincode && resourceSession.pincodeId) { logger.debug( "Resource allowed because pincode session is valid" ); logRequestAudit( { action: true, reason: 104, // valid pincode resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return allowed(res); } if (password && resourceSession.passwordId) { logger.debug( "Resource allowed because password session is valid" ); logRequestAudit( { action: true, reason: 105, // valid password resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return allowed(res); } if ( resource.emailWhitelistEnabled && resourceSession.whitelistId ) { logger.debug( "Resource allowed because whitelist session is valid" ); logRequestAudit( { action: true, reason: 106, // valid email resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return allowed(res); } if (resourceSession.accessTokenId) { logger.debug( "Resource allowed because access token session is valid" ); logRequestAudit( { action: true, reason: 102, // valid access token resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC, apiKey: { name: resourceSession.accessTokenTitle, apiKeyId: resourceSession.accessTokenId } }, parsedBody.data ); return allowed(res); } if (resourceSession.userSessionId && sso) { const userAccessCacheKey = `userAccess:${ resourceSession.userSessionId }:${resource.resourceId}`; let allowedUserData: BasicUserData | null | undefined = localCache.get(userAccessCacheKey); if (allowedUserData === undefined) { allowedUserData = await isUserAllowedToAccessResource( resourceSession.userSessionId, resource, resourceData.org ); localCache.set(userAccessCacheKey, allowedUserData, 5); } if ( allowedUserData !== null && allowedUserData !== undefined ) { logger.debug( "Resource allowed because user session is valid" ); logRequestAudit( { action: true, reason: 107, // valid sso resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC, user: { username: allowedUserData.username, userId: resourceSession.userId } }, parsedBody.data ); return allowed(res, allowedUserData); } } } } // If headerAuthExtendedCompatibility is activated but no clientHeaderAuth provided, force client to challenge if ( headerAuthExtendedCompatibility && headerAuthExtendedCompatibility.extendedCompatibilityIsActivated && !clientHeaderAuth ) { return headerAuthChallenged(res, redirectPath, resource.orgId); } logger.debug("No more auth to check, resource not allowed"); if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Resource access not allowed. Resource ID: ${ resource.resourceId }. IP: ${clientIp}.` ); } logger.debug(`Redirecting to login at ${redirectPath}`); logRequestAudit( { action: false, reason: 299, // no more auth methods resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC }, parsedBody.data ); return notAllowed(res, redirectPath, resource.orgId); } catch (e) { console.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to verify session" ) ); } } function extractResourceSessionToken( sessions: Record, ssl: boolean ) { const prefix = `${config.getRawConfig().server.session_cookie_name}${ ssl ? "_s" : "" }`; const all: { cookieName: string; token: string; priority: number }[] = []; for (const [key, value] of Object.entries(sessions)) { const parts = key.split("."); const timestamp = parts[parts.length - 1]; // check if string is only numbers if (!/^\d+$/.test(timestamp)) { continue; } // cookie name is the key without the timestamp const cookieName = key.slice(0, -timestamp.length - 1); if (cookieName === prefix) { all.push({ cookieName, token: value, priority: parseInt(timestamp) }); } } // sort by priority in desc order all.sort((a, b) => b.priority - a.priority); const latest = all[0]; if (!latest) { return; } return latest.token; } async function notAllowed( res: Response, redirectPath?: string, orgId?: string ) { let loginPage: LoginPage | null = null; if (orgId) { const subscribed = await isSubscribed( // this is fine because the org login page is only a saas feature orgId, tierMatrix.loginPageDomain ); if (subscribed) { loginPage = await getOrgLoginPage(orgId); } } let redirectUrl: string | undefined = undefined; if (redirectPath) { let endpoint: string; if (loginPage && loginPage.domainId && loginPage.fullDomain) { const secure = config .getRawConfig() .app.dashboard_url?.startsWith("https"); const method = secure ? "https" : "http"; endpoint = `${method}://${loginPage.fullDomain}`; } else { endpoint = config.getRawConfig().app.dashboard_url!; } redirectUrl = `${endpoint}${redirectPath}`; } const data = { data: { valid: false, redirectUrl, pangolinVersion: APP_VERSION }, success: true, error: false, message: "Access denied", status: HttpCode.OK }; logger.debug(JSON.stringify(data)); return response(res, data); } function allowed(res: Response, userData?: BasicUserData) { const data = { data: userData !== undefined && userData !== null ? { valid: true, ...userData, pangolinVersion: APP_VERSION } : { valid: true, pangolinVersion: APP_VERSION }, success: true, error: false, message: "Access allowed", status: HttpCode.OK }; return response(res, data); } async function headerAuthChallenged( res: Response, redirectPath?: string, orgId?: string ) { let loginPage: LoginPage | null = null; if (orgId) { const subscribed = await isSubscribed(orgId, tierMatrix.loginPageDomain); // this is fine because the org login page is only a saas feature if (subscribed) { loginPage = await getOrgLoginPage(orgId); } } let redirectUrl: string | undefined = undefined; if (redirectPath) { let endpoint: string; if (loginPage && loginPage.domainId && loginPage.fullDomain) { const secure = config .getRawConfig() .app.dashboard_url?.startsWith("https"); const method = secure ? "https" : "http"; endpoint = `${method}://${loginPage.fullDomain}`; } else { endpoint = config.getRawConfig().app.dashboard_url!; } redirectUrl = `${endpoint}${redirectPath}`; } const data = { data: { headerAuthChallenged: true, valid: false, redirectUrl, pangolinVersion: APP_VERSION }, success: true, error: false, message: "Access denied", status: HttpCode.OK }; logger.debug(JSON.stringify(data)); return response(res, data); } async function isUserAllowedToAccessResource( userSessionId: string, resource: Resource, org: Org ): Promise { const result = await getUserSessionWithUser(userSessionId); if (!result) { return null; } const { user, session } = result; if (!user || !session) { return null; } if ( config.getRawConfig().flags?.require_email_verification && !user.emailVerified ) { return null; } const userOrgRole = await getUserOrgRole(user.userId, resource.orgId); if (!userOrgRole) { return null; } const accessPolicy = await checkOrgAccessPolicy({ org, user, session }); if (!accessPolicy.allowed || accessPolicy.error) { logger.debug(`User not allowed by org access policy because`, { accessPolicy }); return null; } const roleResourceAccess = await getRoleResourceAccess( resource.resourceId, userOrgRole.roleId ); if (roleResourceAccess) { return { username: user.username, email: user.email, name: user.name, role: userOrgRole.roleName }; } const userResourceAccess = await getUserResourceAccess( user.userId, resource.resourceId ); if (userResourceAccess) { return { username: user.username, email: user.email, name: user.name, role: userOrgRole.roleName }; } return null; } async function checkRules( resourceId: number, clientIp: string | undefined, path: string | undefined, ipCC?: string, ipAsn?: number ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> { const ruleCacheKey = `rules:${resourceId}`; let rules: ResourceRule[] | undefined = localCache.get(ruleCacheKey); if (!rules) { rules = await getResourceRules(resourceId); localCache.set(ruleCacheKey, rules, 5); } if (rules.length === 0) { logger.debug("No rules found for resource", resourceId); return; } // sort rules by priority in ascending order rules = rules.sort((a, b) => a.priority - b.priority); for (const rule of rules) { if (!rule.enabled) { continue; } if ( clientIp && rule.match == "CIDR" && isIpInCidr(clientIp, rule.value) ) { return rule.action as any; } else if (clientIp && rule.match == "IP" && clientIp == rule.value) { return rule.action as any; } else if ( path && rule.match == "PATH" && isPathAllowed(rule.value, path) ) { return rule.action as any; } else if ( clientIp && rule.match == "COUNTRY" && (await isIpInGeoIP(ipCC, rule.value)) ) { return rule.action as any; } else if ( clientIp && rule.match == "ASN" && (await isIpInAsn(ipAsn, rule.value)) ) { return rule.action as any; } } return; } export function isPathAllowed(pattern: string, path: string): boolean { logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`); // Normalize and split paths into segments const normalize = (p: string) => p.split("/").filter(Boolean); const patternParts = normalize(pattern); const pathParts = normalize(path); logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`); logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`); // Maximum recursion depth to prevent stack overflow and memory issues const MAX_RECURSION_DEPTH = 100; // Recursive function to try different wildcard matches function matchSegments( patternIndex: number, pathIndex: number, depth: number = 0 ): boolean { // Check recursion depth limit if (depth > MAX_RECURSION_DEPTH) { logger.warn( `Path matching exceeded maximum recursion depth (${MAX_RECURSION_DEPTH}) for pattern "${pattern}" and path "${path}"` ); return false; } const indent = " ".repeat(depth); // Indent based on recursion depth const currentPatternPart = patternParts[patternIndex]; const currentPathPart = pathParts[pathIndex]; logger.debug( `${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"}) [depth=${depth}]` ); // If we've consumed all pattern parts, we should have consumed all path parts if (patternIndex >= patternParts.length) { const result = pathIndex >= pathParts.length; logger.debug( `${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}` ); return result; } // If we've consumed all path parts but still have pattern parts if (pathIndex >= pathParts.length) { // The only way this can match is if all remaining pattern parts are wildcards const remainingPattern = patternParts.slice(patternIndex); const result = remainingPattern.every((p) => p === "*"); logger.debug( `${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}` ); return result; } // For full segment wildcards, try consuming different numbers of path segments if (currentPatternPart === "*") { logger.debug( `${indent}Found wildcard at pattern index ${patternIndex}` ); // Try consuming 0 segments (skip the wildcard) logger.debug( `${indent}Trying to skip wildcard (consume 0 segments)` ); if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) { logger.debug( `${indent}Successfully matched by skipping wildcard` ); return true; } // Try consuming current segment and recursively try rest logger.debug( `${indent}Trying to consume segment "${currentPathPart}" for wildcard` ); if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) { logger.debug( `${indent}Successfully matched by consuming segment for wildcard` ); return true; } logger.debug(`${indent}Failed to match wildcard`); return false; } // Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix") if (currentPatternPart.includes("*")) { logger.debug( `${indent}Found in-segment wildcard in "${currentPatternPart}"` ); // Convert the pattern segment to a regex pattern const regexPattern = currentPatternPart .replace(/\*/g, ".*") // Replace * with .* for regex wildcard .replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed const regex = new RegExp(`^${regexPattern}$`); if (regex.test(currentPathPart)) { logger.debug( `${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"` ); return matchSegments( patternIndex + 1, pathIndex + 1, depth + 1 ); } logger.debug( `${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"` ); return false; } // For regular segments, they must match exactly if (currentPatternPart !== currentPathPart) { logger.debug( `${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"` ); return false; } logger.debug( `${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"` ); // Move to next segments in both pattern and path return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1); } const result = matchSegments(0, 0, 0); logger.debug(`Final result: ${result}`); return result; } async function isIpInGeoIP( ipCountryCode: string | undefined, checkCountryCode: string ): Promise { if (checkCountryCode == "ALL") { return true; } return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase(); } async function isIpInAsn( ipAsn: number | undefined, checkAsn: string ): Promise { // Handle "ALL" special case if (checkAsn === "ALL" || checkAsn === "AS0") { return true; } if (!ipAsn) { return false; } // Normalize the check ASN - remove "AS" prefix if present and convert to number const normalizedCheckAsn = checkAsn.toUpperCase().replace(/^AS/, ""); const checkAsnNumber = parseInt(normalizedCheckAsn, 10); if (isNaN(checkAsnNumber)) { logger.warn(`Invalid ASN format in rule: ${checkAsn}`); return false; } const match = ipAsn === checkAsnNumber; logger.debug( `ASN check: IP ASN ${ipAsn} ${match ? "matches" : "does not match"} rule ASN ${checkAsnNumber}` ); return match; } async function getAsnFromIp(ip: string): Promise { const asnCacheKey = `asn:${ip}`; let cachedAsn: number | undefined = localCache.get(asnCacheKey); if (!cachedAsn) { cachedAsn = await getAsnForIp(ip); // do it locally // Cache for longer since IP ASN doesn't change frequently if (cachedAsn) { localCache.set(asnCacheKey, cachedAsn, 300); // 5 minutes } } return cachedAsn; } async function getCountryCodeFromIp(ip: string): Promise { const geoIpCacheKey = `geoip:${ip}`; let cachedCountryCode: string | undefined = localCache.get(geoIpCacheKey); if (!cachedCountryCode) { cachedCountryCode = await getCountryCodeForIp(ip); // do it locally // Only cache successful lookups to avoid filling cache with undefined values if (cachedCountryCode) { // Cache for longer since IP geolocation doesn't change frequently localCache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes } } return cachedCountryCode; } function extractBasicAuth( headers: Record | undefined ): string | undefined { if (!headers || (!headers.authorization && !headers.Authorization)) { return; } const authHeader = headers.authorization || headers.Authorization; // Check if it's Basic Auth if (!authHeader.startsWith("Basic ")) { logger.debug("Authorization header is not Basic Auth"); return; } try { // Extract the base64 encoded credentials return authHeader.slice("Basic ".length); } catch (error) { logger.debug("Basic Auth: Failed to decode credentials", { error: error instanceof Error ? error.message : "Unknown error" }); } } ================================================ FILE: server/routers/billing/types.ts ================================================ import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db"; export type GetOrgSubscriptionResponse = { subscriptions: Array<{ subscription: Subscription; items: SubscriptionItem[] }>; /** When build === saas, true if org has exceeded plan limits (sites, users, etc.) */ limitsExceeded?: boolean; }; export type GetOrgUsageResponse = { usage: Usage[]; limits: Limit[]; }; export type GetOrgTierResponse = { tier: string | null; active: boolean; }; ================================================ FILE: server/routers/billing/webhooks.ts ================================================ import createHttpError from "http-errors"; import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; export async function billingWebhookHandler( req: Request, res: Response, next: NextFunction ): Promise { // return not found return next( createHttpError(HttpCode.NOT_FOUND, "This endpoint is not in use") ); } ================================================ FILE: server/routers/blueprints/applyJSONBlueprint.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; 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 { applyBlueprint } from "@server/lib/blueprints/applyBlueprint"; const applyBlueprintSchema = z.strictObject({ blueprint: z.string() }); const applyBlueprintParamsSchema = z.strictObject({ orgId: z.string() }); registry.registerPath({ method: "put", path: "/org/{orgId}/blueprint", description: "Apply a base64 encoded JSON blueprint to an organization", tags: [OpenAPITags.Blueprint], request: { params: applyBlueprintParamsSchema, body: { content: { "application/json": { schema: applyBlueprintSchema } } } }, responses: {} }); export async function applyJSONBlueprint( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = applyBlueprintParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const parsedBody = applyBlueprintSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { blueprint } = parsedBody.data; if (!blueprint) { logger.warn("No blueprint provided"); return; } logger.debug(`Received blueprint: ${blueprint}`); try { // first base64 decode the blueprint const decoded = Buffer.from(blueprint, "base64").toString("utf-8"); // then parse the json const blueprintParsed = JSON.parse(decoded); // Update the blueprint in the database await applyBlueprint({ orgId, configData: blueprintParsed, source: "API" }); } catch (error) { logger.error(`Failed to update database from config: ${error}`); return next( createHttpError( HttpCode.BAD_REQUEST, `Failed to update database from config: ${error}` ) ); } return response(res, { data: null, success: true, error: false, message: "Blueprint applied successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/blueprints/applyYAMLBlueprint.ts ================================================ import { OpenAPITags, registry } from "@server/openApi"; import z from "zod"; import { applyBlueprint } from "@server/lib/blueprints/applyBlueprint"; import { NextFunction, Request, Response } from "express"; import logger from "@server/logger"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { fromZodError } from "zod-validation-error"; import response from "@server/lib/response"; import { type Blueprint } from "@server/db"; import { parse as parseYaml } from "yaml"; import { ConfigSchema } from "@server/lib/blueprints/types"; const applyBlueprintSchema = z .object({ name: z.string().min(1).max(255), blueprint: z .string() .min(1) .superRefine((val, ctx) => { try { parseYaml(val); } catch (error) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}` }); } }), source: z.enum(["API", "UI", "CLI"]).optional() }) .strict(); const applyBlueprintParamsSchema = z .object({ orgId: z.string() }) .strict(); export type CreateBlueprintResponse = Blueprint; registry.registerPath({ method: "put", path: "/org/{orgId}/blueprint", description: "Create and apply a YAML blueprint to an organization", tags: [OpenAPITags.Blueprint], request: { params: applyBlueprintParamsSchema, body: { content: { "application/json": { schema: applyBlueprintSchema } } } }, responses: {} }); export async function applyYAMLBlueprint( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = applyBlueprintParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const { orgId } = parsedParams.data; const parsedBody = applyBlueprintSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedBody.error) ) ); } const { blueprint: contents, name, source = "UI" } = parsedBody.data; logger.debug(`Received blueprint:`, contents); const parsedConfig = parseYaml(contents); // apply the validation in advance so that error concerning the format are ruled out first const validationResult = ConfigSchema.safeParse(parsedConfig); if (!validationResult.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(validationResult.error) ) ); } let blueprint: Blueprint | null = null; let error: string | null = null; try { blueprint = await applyBlueprint({ orgId, name, source, configData: parsedConfig }); } catch (err) { // We do nothing, the error is thrown for the other APIs & websockets for backwards compatibility // for this API, the error is already saved in the blueprint and we don't need to handle it logger.error(err); if (err instanceof Error) { error = err.message; } } if (!blueprint) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, error ? error : "An unknown error occurred while applying the blueprint" ) ); } return response(res, { data: blueprint, success: true, error: false, message: "Done", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/blueprints/getBlueprint.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { blueprints, orgs } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { BlueprintData } from "./types"; const getBlueprintSchema = z.strictObject({ blueprintId: z.string().transform(stoi).pipe(z.int().positive()), orgId: z.string() }); async function query(blueprintId: number, orgId: string) { // Get the client const [blueprint] = await db .select({ blueprintId: blueprints.blueprintId, name: blueprints.name, source: blueprints.source, succeeded: blueprints.succeeded, orgId: blueprints.orgId, createdAt: blueprints.createdAt, message: blueprints.message, contents: blueprints.contents }) .from(blueprints) .leftJoin(orgs, eq(blueprints.orgId, orgs.orgId)) .where( and( eq(blueprints.blueprintId, blueprintId), eq(blueprints.orgId, orgId) ) ) .limit(1); if (!blueprint) { return null; } return blueprint; } export type GetBlueprintResponse = BlueprintData; registry.registerPath({ method: "get", path: "/org/{orgId}/blueprint/{blueprintId}", description: "Get a blueprint by its blueprint ID.", tags: [OpenAPITags.Blueprint], request: { params: getBlueprintSchema }, responses: {} }); export async function getBlueprint( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getBlueprintSchema.safeParse(req.params); if (!parsedParams.success) { logger.error( `Error parsing params: ${fromError(parsedParams.error).toString()}` ); return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId, blueprintId } = parsedParams.data; const blueprint = await query(blueprintId, orgId); if (!blueprint) { return next( createHttpError(HttpCode.NOT_FOUND, "Client not found") ); } return response(res, { data: blueprint as BlueprintData, success: true, error: false, message: "Client retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/blueprints/index.ts ================================================ export * from "./listBlueprints"; export * from "./applyYAMLBlueprint"; export * from "./applyJSONBlueprint"; export * from "./getBlueprint"; ================================================ FILE: server/routers/blueprints/listBlueprints.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, blueprints, orgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { sql, eq, desc } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { BlueprintData } from "./types"; const listBluePrintsParamsSchema = z.strictObject({ orgId: z.string() }); const listBluePrintsSchema = z.strictObject({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().nonnegative()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); async function queryBlueprints(orgId: string, limit: number, offset: number) { const res = await db .select({ blueprintId: blueprints.blueprintId, name: blueprints.name, source: blueprints.source, succeeded: blueprints.succeeded, orgId: blueprints.orgId, createdAt: blueprints.createdAt }) .from(blueprints) .leftJoin(orgs, eq(blueprints.orgId, orgs.orgId)) .where(eq(blueprints.orgId, orgId)) .orderBy(desc(blueprints.createdAt)) .limit(limit) .offset(offset); return res; } export type ListBlueprintsResponse = { blueprints: NonNullable< Pick< BlueprintData, | "blueprintId" | "name" | "source" | "succeeded" | "orgId" | "createdAt" >[] >; pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ method: "get", path: "/org/{orgId}/blueprints", description: "List all blueprints for a organization.", tags: [OpenAPITags.Blueprint], request: { params: z.object({ orgId: z.string() }), query: listBluePrintsSchema }, responses: {} }); export async function listBlueprints( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listBluePrintsSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedQuery.error) ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = listBluePrintsParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const { orgId } = parsedParams.data; const blueprintsList = await queryBlueprints( orgId.toString(), limit, offset ); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(blueprints); return response(res, { data: { blueprints: blueprintsList as ListBlueprintsResponse["blueprints"], pagination: { total: count, limit, offset } }, success: true, error: false, message: "Blueprints retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/blueprints/types.ts ================================================ import type { Blueprint } from "@server/db"; export type BlueprintSource = "API" | "UI" | "NEWT" | "CLI"; export type BlueprintData = Omit & { source: BlueprintSource; }; ================================================ FILE: server/routers/certificates/createCertificate.ts ================================================ import { db, Transaction } from "@server/db"; export async function createCertificate( domainId: string, domain: string, trx: Transaction | typeof db ) { return; } ================================================ FILE: server/routers/certificates/types.ts ================================================ export type GetCertificateResponse = { certId: number; domain: string; domainId: string; wildcard: boolean; status: string; // pending, requested, valid, expired, failed expiresAt: string | null; lastRenewalAttempt: Date | null; createdAt: number; updatedAt: number; errorMessage?: string | null; renewalCount: number; }; ================================================ FILE: server/routers/client/archiveClient.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { clients } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const archiveClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "post", path: "/client/{clientId}/archive", description: "Archive a client by its client ID.", tags: [OpenAPITags.Client], request: { params: archiveClientSchema }, responses: {} }); export async function archiveClient( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = archiveClientSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { clientId } = parsedParams.data; // Check if client exists const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { return next( createHttpError( HttpCode.NOT_FOUND, `Client with ID ${clientId} not found` ) ); } if (client.archived) { return next( createHttpError( HttpCode.BAD_REQUEST, `Client with ID ${clientId} is already archived` ) ); } await db.transaction(async (trx) => { // Archive the client await trx .update(clients) .set({ archived: true }) .where(eq(clients.clientId, clientId)); }); return response(res, { data: null, success: true, error: false, message: "Client archived successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to archive client" ) ); } } ================================================ FILE: server/routers/client/blockClient.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { clients } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { sendTerminateClient } from "./terminate"; import { OlmErrorCodes } from "../olm/error"; const blockClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "post", path: "/client/{clientId}/block", description: "Block a client by its client ID.", tags: [OpenAPITags.Client], request: { params: blockClientSchema }, responses: {} }); export async function blockClient( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = blockClientSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { clientId } = parsedParams.data; // Check if client exists const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { return next( createHttpError( HttpCode.NOT_FOUND, `Client with ID ${clientId} not found` ) ); } if (client.blocked) { return next( createHttpError( HttpCode.BAD_REQUEST, `Client with ID ${clientId} is already blocked` ) ); } await db.transaction(async (trx) => { // Block the client await trx .update(clients) .set({ blocked: true, approvalState: "denied" }) .where(eq(clients.clientId, clientId)); // Send terminate signal if there's an associated OLM and it's connected if (client.olmId && client.online) { await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, client.olmId); } }); return response(res, { data: null, success: true, error: false, message: "Client blocked successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to block client" ) ); } } ================================================ FILE: server/routers/client/createClient.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roles, Client, clients, roleClients, userClients, olms, orgs, sites } 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 { eq, and } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import moment from "moment"; import { hashPassword } from "@server/auth/password"; import { isValidIP } from "@server/lib/validators"; import { isIpInCidr } from "@server/lib/ip"; import { listExitNodes } from "#dynamic/lib/exitNodes"; import { generateId } from "@server/auth/sessions/app"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { getUniqueClientName } from "@server/db/names"; import { build } from "@server/build"; const createClientParamsSchema = z.strictObject({ orgId: z.string() }); const createClientSchema = z.strictObject({ name: z.string().min(1).max(255), olmId: z.string(), secret: z.string(), subnet: z.string(), type: z.enum(["olm"]) }); export type CreateClientBody = z.infer; export type CreateClientResponse = Client; registry.registerPath({ method: "put", path: "/org/{orgId}/client", description: "Create a new client for an organization.", tags: [OpenAPITags.Client], request: { params: createClientParamsSchema, body: { content: { "application/json": { schema: createClientSchema } } } }, responses: {} }); export async function createClient( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = createClientSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { name, type, olmId, secret, subnet } = parsedBody.data; const parsedParams = createClientParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); } if (!isValidIP(subnet)) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid subnet format. Please provide a valid IP." ) ); } const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); if (!org) { return next( createHttpError( HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found` ) ); } if (!org.subnet) { return next( createHttpError( HttpCode.BAD_REQUEST, `Organization with ID ${orgId} has no subnet defined` ) ); } if (!isIpInCidr(subnet, org.subnet)) { return next( createHttpError( HttpCode.BAD_REQUEST, "IP is not in the CIDR range of the subnet." ) ); } const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org // make sure the subnet is unique const subnetExistsClients = await db .select() .from(clients) .where( and(eq(clients.subnet, updatedSubnet), eq(clients.orgId, orgId)) ) .limit(1); if (subnetExistsClients.length > 0) { return next( createHttpError( HttpCode.CONFLICT, `Subnet ${updatedSubnet} already exists in clients` ) ); } const subnetExistsSites = await db .select() .from(sites) .where( and(eq(sites.address, updatedSubnet), eq(sites.orgId, orgId)) ) .limit(1); if (subnetExistsSites.length > 0) { return next( createHttpError( HttpCode.CONFLICT, `Subnet ${updatedSubnet} already exists in sites` ) ); } // check if the olmId already exists const [existingOlm] = await db .select() .from(olms) .where(eq(olms.olmId, olmId)) .limit(1); if (existingOlm) { return next( createHttpError( HttpCode.CONFLICT, `OLM with ID ${olmId} already exists` ) ); } let newClient: Client | null = null; await db.transaction(async (trx) => { // TODO: more intelligent way to pick the exit node const exitNodesList = await listExitNodes(orgId); const randomExitNode = exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; if (!randomExitNode) { return next( createHttpError(HttpCode.NOT_FOUND, `No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`) ); } const [adminRole] = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .limit(1); if (!adminRole) { return next( createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) ); } const niceId = await getUniqueClientName(orgId); [newClient] = await trx .insert(clients) .values({ niceId, exitNodeId: randomExitNode.exitNodeId, orgId, name, subnet: updatedSubnet, type, olmId // this is to lock it to a specific olm even if the olm moves across clients }) .returning(); await trx.insert(roleClients).values({ roleId: adminRole.roleId, clientId: newClient.clientId }); if (req.user && req.userOrgRoleId != adminRole.roleId) { // make sure the user can access the client trx.insert(userClients).values({ userId: req.user.userId, clientId: newClient.clientId }); } let secretToUse = secret; if (!secretToUse) { secretToUse = generateId(48); } const secretHash = await hashPassword(secretToUse); await trx.insert(olms).values({ olmId, secretHash, name, clientId: newClient.clientId, dateCreated: moment().toISOString() }); await rebuildClientAssociationsFromClient(newClient, trx); }); return response(res, { data: newClient, success: true, error: false, message: "Site created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/client/createUserClient.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roles, Client, clients, roleClients, userClients, olms, orgs, sites } 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 { eq, and } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import { isValidIP } from "@server/lib/validators"; import { isIpInCidr } from "@server/lib/ip"; import { listExitNodes } from "#dynamic/lib/exitNodes"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { getUniqueClientName } from "@server/db/names"; const paramsSchema = z .object({ orgId: z.string(), userId: z.string() }) .strict(); const bodySchema = z .object({ name: z.string().min(1).max(255), olmId: z.string(), subnet: z.string(), type: z.enum(["olm"]) }) .strict(); export type CreateClientAndOlmBody = z.infer; export type CreateClientAndOlmResponse = Client; registry.registerPath({ method: "put", path: "/org/{orgId}/user/{userId}/client", description: "Create a new client for a user and associate it with an existing olm.", tags: [OpenAPITags.Client], request: { params: paramsSchema, body: { content: { "application/json": { schema: bodySchema } } } }, responses: {} }); export async function createUserClient( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { name, type, olmId, subnet } = parsedBody.data; const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId, userId } = parsedParams.data; if (!isValidIP(subnet)) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid subnet format. Please provide a valid CIDR notation." ) ); } const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); if (!org) { return next( createHttpError( HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found` ) ); } if (!org.subnet) { return next( createHttpError( HttpCode.BAD_REQUEST, `Organization with ID ${orgId} has no subnet defined` ) ); } if (!isIpInCidr(subnet, org.subnet)) { return next( createHttpError( HttpCode.BAD_REQUEST, "IP is not in the CIDR range of the subnet." ) ); } const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org // make sure the subnet is unique const subnetExistsClients = await db .select() .from(clients) .where( and(eq(clients.subnet, updatedSubnet), eq(clients.orgId, orgId)) ) .limit(1); if (subnetExistsClients.length > 0) { return next( createHttpError( HttpCode.CONFLICT, `Subnet ${updatedSubnet} already exists in clients` ) ); } const subnetExistsSites = await db .select() .from(sites) .where( and(eq(sites.address, updatedSubnet), eq(sites.orgId, orgId)) ) .limit(1); if (subnetExistsSites.length > 0) { return next( createHttpError( HttpCode.CONFLICT, `Subnet ${updatedSubnet} already exists in sites` ) ); } // check if the olmId already exists const [existingOlm] = await db .select() .from(olms) .where(eq(olms.olmId, olmId)) .limit(1); if (!existingOlm) { return next( createHttpError( HttpCode.NOT_FOUND, `OLM with ID ${olmId} does not exist` ) ); } if (existingOlm.userId !== userId) { return next( createHttpError( HttpCode.BAD_REQUEST, `OLM with ID ${olmId} does not belong to user with ID ${userId}` ) ); } let newClient: Client | null = null; await db.transaction(async (trx) => { // TODO: more intelligent way to pick the exit node const exitNodesList = await listExitNodes(orgId); const randomExitNode = exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; const [adminRole] = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .limit(1); if (!adminRole) { return next( createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) ); } const niceId = await getUniqueClientName(orgId); [newClient] = await trx .insert(clients) .values({ exitNodeId: randomExitNode.exitNodeId, orgId, niceId, name, subnet: updatedSubnet, type, olmId, // this is to lock it to a specific olm even if the olm moves across clients userId }) .returning(); await trx.insert(roleClients).values({ roleId: adminRole.roleId, clientId: newClient.clientId }); trx.insert(userClients).values({ userId, clientId: newClient.clientId }); await rebuildClientAssociationsFromClient(newClient, trx); }); return response(res, { data: newClient, success: true, error: false, message: "Site created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/client/deleteClient.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, olms } from "@server/db"; import { clients, clientSitesAssociationsCache } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "./terminate"; import { OlmErrorCodes } from "../olm/error"; const deleteClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "delete", path: "/client/{clientId}", description: "Delete a client by its client ID.", tags: [OpenAPITags.Client], request: { params: deleteClientSchema }, responses: {} }); export async function deleteClient( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = deleteClientSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { clientId } = parsedParams.data; const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { return next( createHttpError( HttpCode.NOT_FOUND, `Client with ID ${clientId} not found` ) ); } // Only allow deletion of machine clients (clients without userId) if (client.userId) { return next( createHttpError( HttpCode.BAD_REQUEST, `Cannot delete a user client. User clients must be archived instead.` ) ); } await db.transaction(async (trx) => { // Then delete the client itself const [deletedClient] = await trx .delete(clients) .where(eq(clients.clientId, clientId)) .returning(); const [olm] = await trx .select() .from(olms) .where(eq(olms.clientId, clientId)) .limit(1); // this is a machine client so we also delete the olm if (!client.userId && client.olmId) { await trx.delete(olms).where(eq(olms.olmId, client.olmId)); } await rebuildClientAssociationsFromClient(deletedClient, trx); if (olm) { await sendTerminateClient(deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion } }); return response(res, { data: null, success: true, error: false, message: "Client deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/client/getClient.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, olms, users } from "@server/db"; import { clients, currentFingerprint } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { getUserDeviceName } from "@server/db/names"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; const getClientSchema = z.strictObject({ clientId: z .string() .optional() .transform(stoi) .pipe(z.int().positive().optional()) .optional(), niceId: z.string().optional(), orgId: z.string().optional() }); async function query(clientId?: number, niceId?: string, orgId?: string) { if (clientId) { const [res] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin( currentFingerprint, eq(olms.olmId, currentFingerprint.olmId) ) .leftJoin(users, eq(clients.userId, users.userId)) .limit(1); return res; } else if (niceId && orgId) { const [res] = await db .select() .from(clients) .where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId))) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin( currentFingerprint, eq(olms.olmId, currentFingerprint.olmId) ) .leftJoin(users, eq(clients.userId, users.userId)) .limit(1); return res; } } type PostureData = { biometricsEnabled?: boolean | null | "-"; diskEncrypted?: boolean | null | "-"; firewallEnabled?: boolean | null | "-"; autoUpdatesEnabled?: boolean | null | "-"; tpmAvailable?: boolean | null | "-"; windowsAntivirusEnabled?: boolean | null | "-"; macosSipEnabled?: boolean | null | "-"; macosGatekeeperEnabled?: boolean | null | "-"; macosFirewallStealthMode?: boolean | null | "-"; linuxAppArmorEnabled?: boolean | null | "-"; linuxSELinuxEnabled?: boolean | null | "-"; }; function maskPostureDataWithPlaceholder(posture: PostureData): PostureData { const masked: PostureData = {}; for (const key of Object.keys(posture) as (keyof PostureData)[]) { if (posture[key] !== undefined && posture[key] !== null) { (masked as Record)[key] = "-"; } } return masked; } function getPlatformPostureData( platform: string | null | undefined, fingerprint: typeof currentFingerprint.$inferSelect | null ): PostureData | null { if (!fingerprint) return null; const normalizedPlatform = platform?.toLowerCase() || "unknown"; const posture: PostureData = {}; // Windows: Hard drive encryption, Firewall, Auto updates, TPM availability, Windows Antivirus status if (normalizedPlatform === "windows") { if ( fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined ) { posture.diskEncrypted = fingerprint.diskEncrypted; } if ( fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined ) { posture.firewallEnabled = fingerprint.firewallEnabled; } if ( fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined ) { posture.tpmAvailable = fingerprint.tpmAvailable; } if ( fingerprint.windowsAntivirusEnabled !== null && fingerprint.windowsAntivirusEnabled !== undefined ) { posture.windowsAntivirusEnabled = fingerprint.windowsAntivirusEnabled; } } // macOS: Hard drive encryption, Biometric configuration, Firewall, System Integrity Protection (SIP), Gatekeeper, Firewall stealth mode else if (normalizedPlatform === "macos") { if ( fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined ) { posture.diskEncrypted = fingerprint.diskEncrypted; } if ( fingerprint.biometricsEnabled !== null && fingerprint.biometricsEnabled !== undefined ) { posture.biometricsEnabled = fingerprint.biometricsEnabled; } if ( fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined ) { posture.firewallEnabled = fingerprint.firewallEnabled; } if ( fingerprint.macosSipEnabled !== null && fingerprint.macosSipEnabled !== undefined ) { posture.macosSipEnabled = fingerprint.macosSipEnabled; } if ( fingerprint.macosGatekeeperEnabled !== null && fingerprint.macosGatekeeperEnabled !== undefined ) { posture.macosGatekeeperEnabled = fingerprint.macosGatekeeperEnabled; } if ( fingerprint.macosFirewallStealthMode !== null && fingerprint.macosFirewallStealthMode !== undefined ) { posture.macosFirewallStealthMode = fingerprint.macosFirewallStealthMode; } if ( fingerprint.autoUpdatesEnabled !== null && fingerprint.autoUpdatesEnabled !== undefined ) { posture.autoUpdatesEnabled = fingerprint.autoUpdatesEnabled; } } // Linux: Hard drive encryption, Firewall, AppArmor, SELinux, TPM availability else if (normalizedPlatform === "linux") { if ( fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined ) { posture.diskEncrypted = fingerprint.diskEncrypted; } if ( fingerprint.firewallEnabled !== null && fingerprint.firewallEnabled !== undefined ) { posture.firewallEnabled = fingerprint.firewallEnabled; } if ( fingerprint.linuxAppArmorEnabled !== null && fingerprint.linuxAppArmorEnabled !== undefined ) { posture.linuxAppArmorEnabled = fingerprint.linuxAppArmorEnabled; } if ( fingerprint.linuxSELinuxEnabled !== null && fingerprint.linuxSELinuxEnabled !== undefined ) { posture.linuxSELinuxEnabled = fingerprint.linuxSELinuxEnabled; } if ( fingerprint.tpmAvailable !== null && fingerprint.tpmAvailable !== undefined ) { posture.tpmAvailable = fingerprint.tpmAvailable; } } // iOS: Biometric configuration else if (normalizedPlatform === "ios") { // none supported yet } // Android: Screen lock, Biometric configuration, Hard drive encryption else if (normalizedPlatform === "android") { if ( fingerprint.diskEncrypted !== null && fingerprint.diskEncrypted !== undefined ) { posture.diskEncrypted = fingerprint.diskEncrypted; } } // Only return if we have at least one posture field return Object.keys(posture).length > 0 ? posture : null; } export type GetClientResponse = NonNullable< Awaited> >["clients"] & { olmId: string | null; agent: string | null; olmVersion: string | null; userEmail: string | null; userName: string | null; userUsername: string | null; fingerprint: { username: string | null; hostname: string | null; platform: string | null; osVersion: string | null; kernelVersion: string | null; arch: string | null; deviceModel: string | null; serialNumber: string | null; firstSeen: number | null; lastSeen: number | null; } | null; posture: PostureData | null; }; registry.registerPath({ method: "get", path: "/org/{orgId}/client/{niceId}", description: "Get a client by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.", tags: [OpenAPITags.Site], request: { params: z.object({ orgId: z.string(), niceId: z.string() }) }, responses: {} }); registry.registerPath({ method: "get", path: "/client/{clientId}", description: "Get a client by its client ID.", tags: [OpenAPITags.Client], request: { params: z.object({ clientId: z.number() }) }, responses: {} }); export async function getClient( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getClientSchema.safeParse(req.params); if (!parsedParams.success) { logger.error( `Error parsing params: ${fromError(parsedParams.error).toString()}` ); return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { clientId, niceId, orgId } = parsedParams.data; const client = await query(clientId, niceId, orgId); if (!client) { return next( createHttpError(HttpCode.NOT_FOUND, "Client not found") ); } const isUserDevice = client.user !== null && client.user !== undefined; // Replace name with device name if OLM exists let clientName = client.clients.name; if (client.olms && isUserDevice) { const model = client.currentFingerprint?.deviceModel || null; clientName = getUserDeviceName(model, client.clients.name); } // Build fingerprint data if available const fingerprintData = client.currentFingerprint ? { username: client.currentFingerprint.username || null, hostname: client.currentFingerprint.hostname || null, platform: client.currentFingerprint.platform || null, osVersion: client.currentFingerprint.osVersion || null, kernelVersion: client.currentFingerprint.kernelVersion || null, arch: client.currentFingerprint.arch || null, deviceModel: client.currentFingerprint.deviceModel || null, serialNumber: client.currentFingerprint.serialNumber || null, firstSeen: client.currentFingerprint.firstSeen || null, lastSeen: client.currentFingerprint.lastSeen || null } : null; // Build posture data if available (platform-specific) // Licensed: real values; not licensed: same keys but values set to "-" const rawPosture = getPlatformPostureData( client.currentFingerprint?.platform || null, client.currentFingerprint ); const isOrgLicensed = await isLicensedOrSubscribed( client.clients.orgId, tierMatrix.devicePosture ); const postureData: PostureData | null = rawPosture ? isOrgLicensed ? rawPosture : maskPostureDataWithPlaceholder(rawPosture) : null; const data: GetClientResponse = { ...client.clients, name: clientName, olmId: client.olms ? client.olms.olmId : null, agent: client.olms?.agent || null, olmVersion: client.olms?.version || null, userEmail: client.user?.email ?? null, userName: client.user?.name ?? null, userUsername: client.user?.username ?? null, fingerprint: fingerprintData, posture: postureData }; return response(res, { data, success: true, error: false, message: "Client retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/client/index.ts ================================================ export * from "./pickClientDefaults"; export * from "./createClient"; export * from "./deleteClient"; export * from "./archiveClient"; export * from "./unarchiveClient"; export * from "./blockClient"; export * from "./unblockClient"; export * from "./listClients"; export * from "./listUserDevices"; export * from "./updateClient"; export * from "./getClient"; export * from "./createUserClient"; ================================================ FILE: server/routers/client/listClients.ts ================================================ import { clients, clientSitesAssociationsCache, currentFingerprint, db, olms, orgs, roleClients, sites, userClients, users } 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 type { PaginatedResponse } from "@server/types/Pagination"; import { and, asc, desc, eq, inArray, 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 { try { const cachedVersion = olmVersionCache.get("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 listClientsParamsSchema = z.strictObject({ orgId: z.string() }); const listClientsSchema = z.object({ pageSize: z.coerce .number() // 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() // 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(["name", "megabytesIn", "megabytesOut"]) .optional() .catch(undefined) .openapi({ type: "string", enum: ["name", "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 queryClientsBase() { 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 }) .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)); } async function getSiteAssociations(clientIds: number[]) { if (clientIds.length === 0) return []; return db .select({ clientId: clientSitesAssociationsCache.clientId, siteId: clientSitesAssociationsCache.siteId, siteName: sites.name, siteNiceId: sites.niceId }) .from(clientSitesAssociationsCache) .leftJoin(sites, eq(clientSitesAssociationsCache.siteId, sites.siteId)) .where(inArray(clientSitesAssociationsCache.clientId, clientIds)); } type ClientWithSites = Awaited>[0] & { sites: Array<{ siteId: number; siteName: string | null; siteNiceId: string | null; }>; olmUpdateAvailable?: boolean; }; type OlmWithUpdateAvailable = ClientWithSites; export type ListClientsResponse = PaginatedResponse<{ clients: Array; }>; registry.registerPath({ method: "get", path: "/org/{orgId}/clients", description: "List all clients for an organization.", tags: [OpenAPITags.Client], request: { query: listClientsSchema, params: listClientsParamsSchema }, responses: {} }); export async function listClients( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listClientsSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const { page, pageSize, online, query, status, sort_by, order } = parsedQuery.data; const parsedParams = listClientsParamsSchema.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`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), isNull(clients.userId) ) ]; if (typeof online !== "undefined") { conditions.push(eq(clients.online, online)); } if (status.length > 0) { const filterAggregates: (SQL | undefined)[] = []; 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(pageSize) .offset(pageSize * (page - 1)) .orderBy( sort_by ? order === "asc" ? asc(clients[sort_by]) : desc(clients[sort_by]) : asc(clients.name) ); const [clientsList, totalCount] = await Promise.all([ listMachinesQuery, countQuery ]); // Get associated sites for all clients const clientIds = clientsList.map((client) => client.clientId); const siteAssociations = await getSiteAssociations(clientIds); // Group site associations by client ID const sitesByClient = siteAssociations.reduce( (acc, association) => { if (!acc[association.clientId]) { acc[association.clientId] = []; } acc[association.clientId].push({ siteId: association.siteId, siteName: association.siteName, siteNiceId: association.siteNiceId }); return acc; }, {} as Record< number, Array<{ siteId: number; siteName: string | null; siteNiceId: string | null; }> > ); // Merge clients with their site associations and replace name with device name const clientsWithSites = clientsList.map((client) => { return { ...client, sites: sitesByClient[client.clientId] || [] }; }); const latestOlVersionPromise = getLatestOlmVersion(); const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map( (client) => { const OlmWithUpdate: OlmWithUpdateAvailable = { ...client }; // 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 latestOlVersion = await latestOlVersionPromise; if (latestOlVersion) { olmsWithUpdates.forEach((client) => { try { client.olmUpdateAvailable = semver.lt( client.olmVersion ? client.olmVersion : "", latestOlVersion ); } 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(res, { data: { clients: 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") ); } } ================================================ FILE: server/routers/client/listUserDevices.ts ================================================ 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 { try { const cachedVersion = olmVersionCache.get("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() // 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() // 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 >[0] & { olmUpdateAvailable?: boolean; }; export type ListUserDevicesResponse = PaginatedResponse<{ devices: Array; }>; registry.registerPath({ method: "get", path: "/org/{orgId}/user-devices", description: "List all user devices for an organization.", tags: [OpenAPITags.Client], request: { query: listUserDevicesSchema, params: listUserDevicesParamsSchema }, responses: {} }); export async function listUserDevices( req: Request, res: Response, next: NextFunction ): Promise { 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`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, 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 | 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(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") ); } } ================================================ FILE: server/routers/client/pickClientDefaults.ts ================================================ import { Request, Response, NextFunction } from "express"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { generateId } from "@server/auth/sessions/app"; import { getNextAvailableClientSubnet } from "@server/lib/ip"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; export type PickClientDefaultsResponse = { olmId: string; olmSecret: string; subnet: string; }; const pickClientDefaultsSchema = z.strictObject({ orgId: z.string() }); registry.registerPath({ method: "get", path: "/org/{orgId}/pick-client-defaults", description: "Return pre-requisite data for creating a client.", tags: [OpenAPITags.Client], request: { params: pickClientDefaultsSchema }, responses: {} }); export async function pickClientDefaults( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = pickClientDefaultsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const olmId = generateId(15); const secret = generateId(48); const newSubnet = await getNextAvailableClientSubnet(orgId); if (!newSubnet) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "No available subnet found" ) ); } const subnet = newSubnet.split("/")[0]; return response(res, { data: { olmId: olmId, olmSecret: secret, subnet: subnet }, success: true, error: false, message: "Organization retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/client/targets.ts ================================================ import { sendToClient } from "#dynamic/routers/ws"; import { db, olms, Transaction } from "@server/db"; import { canCompress } from "@server/lib/clientVersionChecks"; import { Alias, SubnetProxyTarget } from "@server/lib/ip"; import logger from "@server/logger"; import { eq } from "drizzle-orm"; export async function addTargets( newtId: string, targets: SubnetProxyTarget[], version?: string | null ) { await sendToClient( newtId, { type: `newt/wg/targets/add`, data: targets }, { incrementConfigVersion: true, compress: canCompress(version, "newt") } ); } export async function removeTargets( newtId: string, targets: SubnetProxyTarget[], version?: string | null ) { await sendToClient( newtId, { type: `newt/wg/targets/remove`, data: targets }, { incrementConfigVersion: true, compress: canCompress(version, "newt") } ); } export async function updateTargets( newtId: string, targets: { oldTargets: SubnetProxyTarget[]; newTargets: SubnetProxyTarget[]; }, version?: string | null ) { await sendToClient( newtId, { type: `newt/wg/targets/update`, data: { oldTargets: targets.oldTargets, newTargets: targets.newTargets } }, { incrementConfigVersion: true, compress: canCompress(version, "newt") } ).catch((error) => { logger.warn(`Error sending message:`, error); }); } export async function addPeerData( clientId: number, siteId: number, remoteSubnets: string[], aliases: Alias[], olmId?: string, version?: string | null ) { if (!olmId) { const [olm] = await db .select() .from(olms) .where(eq(olms.clientId, clientId)) .limit(1); if (!olm) { return; // ignore this because an olm might not be associated with the client anymore } olmId = olm.olmId; version = olm.version; } await sendToClient( olmId, { type: `olm/wg/peer/data/add`, data: { siteId: siteId, remoteSubnets: remoteSubnets, aliases: aliases } }, { incrementConfigVersion: true, compress: canCompress(version, "olm") } ).catch((error) => { logger.warn(`Error sending message:`, error); }); } export async function removePeerData( clientId: number, siteId: number, remoteSubnets: string[], aliases: Alias[], olmId?: string, version?: string | null ) { if (!olmId) { const [olm] = await db .select() .from(olms) .where(eq(olms.clientId, clientId)) .limit(1); if (!olm) { return; } olmId = olm.olmId; version = olm.version; } await sendToClient( olmId, { type: `olm/wg/peer/data/remove`, data: { siteId: siteId, remoteSubnets: remoteSubnets, aliases: aliases } }, { incrementConfigVersion: true, compress: canCompress(version, "olm") } ).catch((error) => { logger.warn(`Error sending message:`, error); }); } export async function updatePeerData( clientId: number, siteId: number, remoteSubnets: | { oldRemoteSubnets: string[]; newRemoteSubnets: string[]; } | undefined, aliases: | { oldAliases: Alias[]; newAliases: Alias[]; } | undefined, olmId?: string, version?: string | null ) { if (!olmId) { const [olm] = await db .select() .from(olms) .where(eq(olms.clientId, clientId)) .limit(1); if (!olm) { return; } olmId = olm.olmId; version = olm.version; } await sendToClient( olmId, { type: `olm/wg/peer/data/update`, data: { siteId: siteId, ...remoteSubnets, ...aliases } }, { incrementConfigVersion: true, compress: canCompress(version, "olm") } ).catch((error) => { logger.warn(`Error sending message:`, error); }); } ================================================ FILE: server/routers/client/terminate.ts ================================================ import { sendToClient } from "#dynamic/routers/ws"; import { db, olms } from "@server/db"; import { eq } from "drizzle-orm"; import { OlmErrorCodes } from "../olm/error"; export async function sendTerminateClient( clientId: number, error: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes], olmId?: string | null ) { if (!olmId) { const [olm] = await db .select() .from(olms) .where(eq(olms.clientId, clientId)) .limit(1); if (!olm) { throw new Error(`Olm with ID ${clientId} not found`); } olmId = olm.olmId; } await sendToClient(olmId, { type: `olm/terminate`, data: { code: error.code, message: error.message } }); } ================================================ FILE: server/routers/client/unarchiveClient.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { clients } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const unarchiveClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "post", path: "/client/{clientId}/unarchive", description: "Unarchive a client by its client ID.", tags: [OpenAPITags.Client], request: { params: unarchiveClientSchema }, responses: {} }); export async function unarchiveClient( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = unarchiveClientSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { clientId } = parsedParams.data; // Check if client exists const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { return next( createHttpError( HttpCode.NOT_FOUND, `Client with ID ${clientId} not found` ) ); } if (!client.archived) { return next( createHttpError( HttpCode.BAD_REQUEST, `Client with ID ${clientId} is not archived` ) ); } // Unarchive the client await db .update(clients) .set({ archived: false }) .where(eq(clients.clientId, clientId)); return response(res, { data: null, success: true, error: false, message: "Client unarchived successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to unarchive client" ) ); } } ================================================ FILE: server/routers/client/unblockClient.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { clients } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const unblockClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "post", path: "/client/{clientId}/unblock", description: "Unblock a client by its client ID.", tags: [OpenAPITags.Client], request: { params: unblockClientSchema }, responses: {} }); export async function unblockClient( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = unblockClientSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { clientId } = parsedParams.data; // Check if client exists const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { return next( createHttpError( HttpCode.NOT_FOUND, `Client with ID ${clientId} not found` ) ); } if (!client.blocked) { return next( createHttpError( HttpCode.BAD_REQUEST, `Client with ID ${clientId} is not blocked` ) ); } // Unblock the client await db .update(clients) .set({ blocked: false, approvalState: null }) .where(eq(clients.clientId, clientId)); return response(res, { data: null, success: true, error: false, message: "Client unblocked successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to unblock client" ) ); } } ================================================ FILE: server/routers/client/updateClient.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { clients } 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 { eq, and, ne } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const updateClientParamsSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) }); const updateClientSchema = z.strictObject({ name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional() }); export type UpdateClientBody = z.infer; registry.registerPath({ method: "post", path: "/client/{clientId}", description: "Update a client by its client ID.", tags: [OpenAPITags.Client], request: { params: updateClientParamsSchema, body: { content: { "application/json": { schema: updateClientSchema } } } }, responses: {} }); export async function updateClient( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = updateClientSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { name, niceId } = parsedBody.data; const parsedParams = updateClientParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { clientId } = parsedParams.data; // Fetch the client to make sure it exists and the user has access to it const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { return next( createHttpError( HttpCode.NOT_FOUND, `Client with ID ${clientId} not found` ) ); } // if niceId is provided, check if it's already in use by another client if (niceId) { const [existingClient] = await db .select() .from(clients) .where( and( eq(clients.niceId, niceId), eq(clients.orgId, clients.orgId), ne(clients.clientId, clientId) ) ) .limit(1); if (existingClient) { return next( createHttpError( HttpCode.CONFLICT, `A client with niceId "${niceId}" already exists` ) ); } } const updatedClient = await db .update(clients) .set({ name, niceId }) .where(eq(clients.clientId, clientId)) .returning(); return response(res, { data: updatedClient, success: true, error: false, message: "Client updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/domain/createOrgDomain.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, Domain, domains, OrgDomains, orgDomains, dnsRecords } 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 { subdomainSchema } from "@server/lib/schemas"; import { generateId } from "@server/auth/sessions/app"; import { eq, and } from "drizzle-orm"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators"; import { build } from "@server/build"; import config from "@server/lib/config"; const paramsSchema = z.strictObject({ orgId: z.string() }); const bodySchema = z.strictObject({ type: z.enum(["ns", "cname", "wildcard"]), baseDomain: subdomainSchema, certResolver: z.string().optional().nullable(), preferWildcardCert: z.boolean().optional().nullable() // optional, only for wildcard }); export type CreateDomainResponse = { domainId: string; nsRecords?: string[]; cnameRecords?: { baseDomain: string; value: string }[]; aRecords?: { baseDomain: string; value: string }[]; txtRecords?: { baseDomain: string; value: string }[]; certResolver?: string | null; preferWildcardCert?: boolean | null; }; // Helper to check if a domain is a subdomain or equal to another domain function isSubdomainOrEqual(a: string, b: string): boolean { const aParts = a.toLowerCase().split("."); const bParts = b.toLowerCase().split("."); if (aParts.length < bParts.length) return false; return aParts.slice(-bParts.length).join(".") === bParts.join("."); } export async function createOrgDomain( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const { type, baseDomain, certResolver, preferWildcardCert } = parsedBody.data; if (build == "oss") { if (type !== "wildcard") { return next( createHttpError( HttpCode.NOT_IMPLEMENTED, "Creating NS or CNAME records is not supported" ) ); } } else if (build == "saas") { if (type !== "ns" && type !== "cname") { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid domain type. Only NS, CNAME are allowed." ) ); } } // allow wildacard, cname, and ns in enterprise // Validate organization exists if (!isValidDomain(baseDomain)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid domain format") ); } if (isSecondLevelDomain(baseDomain) && type == "cname") { // many providers dont allow cname for this. Lets prevent it for the user for now return next( createHttpError( HttpCode.BAD_REQUEST, "You cannot create a CNAME record on a root domain. RFC 1912 § 2.4 prohibits CNAME records at the zone apex. Please use a subdomain." ) ); } if (build == "saas") { const usage = await usageService.getUsage(orgId, FeatureId.DOMAINS); if (!usage) { return next( createHttpError( HttpCode.NOT_FOUND, "No usage data found for this organization" ) ); } const rejectDomains = await usageService.checkLimitSet( orgId, FeatureId.DOMAINS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 } // We need to add one to know if we are violating the limit ); if (rejectDomains) { return next( createHttpError( HttpCode.FORBIDDEN, "Domain limit exceeded. Please upgrade your plan." ) ); } } let aRecords: CreateDomainResponse["aRecords"]; let cnameRecords: CreateDomainResponse["cnameRecords"]; let txtRecords: CreateDomainResponse["txtRecords"]; let nsRecords: CreateDomainResponse["nsRecords"]; let returned: Domain | undefined; await db.transaction(async (trx) => { const [existing] = await trx .select() .from(domains) .where( and( eq(domains.baseDomain, baseDomain), eq(domains.type, type) ) ) .leftJoin( orgDomains, eq(orgDomains.domainId, domains.domainId) ); if (existing) { const { domains: existingDomain, orgDomains: existingOrgDomain } = existing; // user alrady added domain to this account // always reject if (existingOrgDomain?.orgId === orgId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Domain is already added to this org" ) ); } // domain already exists elsewhere // check if it's already fully verified if (existingDomain.verified) { return next( createHttpError( HttpCode.BAD_REQUEST, "Domain is already verified to an org" ) ); } } // --- Domain overlap logic --- // Only consider existing verified domains const verifiedDomains = await trx .select() .from(domains) .where(eq(domains.verified, true)); if (type == "cname") { // Block if a verified CNAME exists at the same name const cnameExists = verifiedDomains.some( (d) => d.type === "cname" && d.baseDomain === baseDomain ); if (cnameExists) { return next( createHttpError( HttpCode.BAD_REQUEST, `A CNAME record already exists for ${baseDomain}. Only one CNAME record is allowed per domain.` ) ); } // Block if a verified NS exists at or below (same or subdomain) const nsAtOrBelow = verifiedDomains.some( (d) => d.type === "ns" && (isSubdomainOrEqual(baseDomain, d.baseDomain) || baseDomain === d.baseDomain) ); if (nsAtOrBelow) { return next( createHttpError( HttpCode.BAD_REQUEST, `A nameserver (NS) record exists at or below ${baseDomain}. You cannot create a CNAME record here.` ) ); } } else if (type == "ns") { // Block if a verified NS exists at or below (same or subdomain) const nsAtOrBelow = verifiedDomains.some( (d) => d.type === "ns" && (isSubdomainOrEqual(baseDomain, d.baseDomain) || baseDomain === d.baseDomain) ); if (nsAtOrBelow) { return next( createHttpError( HttpCode.BAD_REQUEST, `A nameserver (NS) record already exists at or below ${baseDomain}. You cannot create another NS record here.` ) ); } } else if (type == "wildcard") { // TODO: Figure out how to handle wildcards } const domainId = generateId(15); const [insertedDomain] = await trx .insert(domains) .values({ domainId, baseDomain, type, verified: type === "wildcard" ? true : false, certResolver: certResolver || null, preferWildcardCert: preferWildcardCert || false }) .returning(); returned = insertedDomain; // add domain to account await trx .insert(orgDomains) .values({ orgId, domainId }) .returning(); // Prepare DNS records to insert const recordsToInsert = []; // TODO: This needs to be cross region and not hardcoded if (type === "ns") { nsRecords = config.getRawConfig().dns.nameservers as string[]; // Save NS records to database for (const nsValue of nsRecords) { recordsToInsert.push({ domainId, recordType: "NS", baseDomain: baseDomain, value: nsValue, verified: false }); } } else if (type === "cname") { cnameRecords = [ { value: `${domainId}.${config.getRawConfig().dns.cname_extension}`, baseDomain: baseDomain }, { value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`, baseDomain: `_acme-challenge.${baseDomain}` } ]; // Save CNAME records to database for (const cnameRecord of cnameRecords) { recordsToInsert.push({ domainId, recordType: "CNAME", baseDomain: cnameRecord.baseDomain, value: cnameRecord.value, verified: false }); } } else if (type === "wildcard") { aRecords = [ { value: `Server IP Address`, baseDomain: `*.${baseDomain}` }, { value: `Server IP Address`, baseDomain: `${baseDomain}` } ]; // Save A records to database for (const aRecord of aRecords) { recordsToInsert.push({ domainId, recordType: "A", baseDomain: aRecord.baseDomain, value: aRecord.value, verified: true }); } } // Insert all DNS records in batch if (recordsToInsert.length > 0) { await trx.insert(dnsRecords).values(recordsToInsert); } await usageService.add(orgId, FeatureId.DOMAINS, 1, trx); }); if (!returned) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create domain" ) ); } return response(res, { data: { domainId: returned.domainId, cnameRecords, txtRecords, nsRecords, aRecords, certResolver: returned.certResolver, preferWildcardCert: returned.preferWildcardCert }, success: true, error: false, message: "Domain created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/domain/deleteOrgDomain.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, domains, OrgDomains, orgDomains } 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 { and, eq } from "drizzle-orm"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; const paramsSchema = z.strictObject({ domainId: z.string(), orgId: z.string() }); export type DeleteAccountDomainResponse = { success: boolean; }; export async function deleteAccountDomain( req: Request, res: Response, next: NextFunction ): Promise { try { const parsed = paramsSchema.safeParse(req.params); if (!parsed.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsed.error).toString() ) ); } const { domainId, orgId } = parsed.data; await db.transaction(async (trx) => { const [existing] = await trx .select() .from(orgDomains) .where( and( eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId) ) ) .innerJoin(domains, eq(orgDomains.domainId, domains.domainId)); if (!existing) { return next( createHttpError( HttpCode.NOT_FOUND, "Domain not found for this account" ) ); } if (existing.domains.configManaged) { return next( createHttpError( HttpCode.BAD_REQUEST, "Cannot delete a domain that is managed by the config" ) ); } await trx .delete(orgDomains) .where( and( eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId) ) ); await trx.delete(domains).where(eq(domains.domainId, domainId)); await usageService.add(orgId, FeatureId.DOMAINS, -1, trx); }); return response(res, { data: { success: true }, success: true, error: false, message: "Domain deleted from account successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/domain/getDNSRecords.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, dnsRecords } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { getServerIp } from "@server/lib/serverIpService"; // your in-memory IP module const getDNSRecordsSchema = z.strictObject({ domainId: z.string(), orgId: z.string() }); async function query(domainId: string) { const records = await db .select() .from(dnsRecords) .where(eq(dnsRecords.domainId, domainId)); return records; } export type GetDNSRecordsResponse = Awaited>; registry.registerPath({ method: "get", path: "/org/{orgId}/domain/{domainId}/dns-records", description: "Get all DNS records for a domain by domainId.", tags: [OpenAPITags.Domain], request: { params: z.object({ domainId: z.string(), orgId: z.string() }) }, responses: {} }); export async function getDNSRecords( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getDNSRecordsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { domainId } = parsedParams.data; const records = await query(domainId); if (!records || records.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, "No DNS records found for this domain" ) ); } const serverIp = getServerIp(); // Override value for type A or wildcard records const updatedRecords = records.map((record) => { if ( (record.recordType === "A" || record.baseDomain === "*") && serverIp ) { return { ...record, value: serverIp }; } return record; }); return response(res, { data: updatedRecords, success: true, error: false, message: "DNS records retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/domain/getDomain.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, domains } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { domain } from "zod/v4/core/regexes"; const getDomainSchema = z.strictObject({ domainId: z.string().optional(), orgId: z.string().optional() }); async function query(domainId?: string, orgId?: string) { if (domainId) { const [res] = await db .select() .from(domains) .where(eq(domains.domainId, domainId)) .limit(1); return res; } } export type GetDomainResponse = NonNullable>>; registry.registerPath({ method: "get", path: "/org/{orgId}/domain/{domainId}", description: "Get a domain by domainId.", tags: [OpenAPITags.Domain], request: { params: z.object({ domainId: z.string(), orgId: z.string() }) }, responses: {} }); export async function getDomain( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getDomainSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId, domainId } = parsedParams.data; const domain = await query(domainId, orgId); if (!domain) { return next( createHttpError(HttpCode.NOT_FOUND, "Domain not found") ); } return response(res, { data: domain, success: true, error: false, message: "Domain retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/domain/index.ts ================================================ export * from "./listDomains"; export * from "./createOrgDomain"; export * from "./deleteOrgDomain"; export * from "./restartOrgDomain"; export * from "./getDomain"; export * from "./getDNSRecords"; export * from "./updateDomain"; ================================================ FILE: server/routers/domain/listDomains.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { domains, orgDomains, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const listDomainsParamsSchema = z.strictObject({ orgId: z.string() }); const listDomainsSchema = z.strictObject({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().nonnegative()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); async function queryDomains(orgId: string, limit: number, offset: number) { const res = await db .select({ domainId: domains.domainId, baseDomain: domains.baseDomain, verified: domains.verified, type: domains.type, failed: domains.failed, tries: domains.tries, configManaged: domains.configManaged, certResolver: domains.certResolver, preferWildcardCert: domains.preferWildcardCert, errorMessage: domains.errorMessage }) .from(orgDomains) .where(eq(orgDomains.orgId, orgId)) .innerJoin(domains, eq(domains.domainId, orgDomains.domainId)) .limit(limit) .offset(offset); return res; } export type ListDomainsResponse = { domains: NonNullable>>; pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ method: "get", path: "/org/{orgId}/domains", description: "List all domains for a organization.", tags: [OpenAPITags.Domain], request: { params: z.object({ orgId: z.string() }), query: listDomainsSchema }, responses: {} }); export async function listDomains( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listDomainsSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = listDomainsParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const domainsList = await queryDomains(orgId.toString(), limit, offset); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(domains); return response(res, { data: { domains: domainsList, pagination: { total: count, limit, offset } }, success: true, error: false, message: "Domains retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/domain/restartOrgDomain.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, domains } 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 { and, eq } from "drizzle-orm"; const paramsSchema = z.strictObject({ domainId: z.string(), orgId: z.string() }); export type RestartOrgDomainResponse = { success: boolean; }; export async function restartOrgDomain( req: Request, res: Response, next: NextFunction ): Promise { try { const parsed = paramsSchema.safeParse(req.params); if (!parsed.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsed.error).toString() ) ); } const { domainId, orgId } = parsed.data; await db .update(domains) .set({ failed: false, tries: 0 }) .where(and(eq(domains.domainId, domainId))); return response(res, { data: { success: true }, success: true, error: false, message: "Domain restarted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/domain/types.ts ================================================ export type CheckDomainAvailabilityResponse = { available: boolean; options: { domainNamespaceId: string; domainId: string; fullDomain: string; }[]; }; ================================================ FILE: server/routers/domain/updateDomain.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, domains, orgDomains } 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, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.strictObject({ orgId: z.string(), domainId: z.string() }); const bodySchema = z.strictObject({ certResolver: z.string().optional().nullable(), preferWildcardCert: z.boolean().optional().nullable() }); export type UpdateDomainResponse = { domainId: string; certResolver: string | null; preferWildcardCert: boolean | null; }; registry.registerPath({ method: "patch", path: "/org/{orgId}/domain/{domainId}", description: "Update a domain by domainId.", tags: [OpenAPITags.Domain], request: { params: z.object({ domainId: z.string(), orgId: z.string() }) }, responses: {} }); export async function updateOrgDomain( req: Request, res: Response, next: NextFunction ): Promise { 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, domainId } = parsedParams.data; const { certResolver, preferWildcardCert } = parsedBody.data; const [orgDomain] = await db .select() .from(orgDomains) .where( and( eq(orgDomains.orgId, orgId), eq(orgDomains.domainId, domainId) ) ); if (!orgDomain) { return next( createHttpError( HttpCode.NOT_FOUND, "Domain not found or does not belong to this organization" ) ); } const [existingDomain] = await db .select() .from(domains) .where(eq(domains.domainId, domainId)); if (!existingDomain) { return next( createHttpError(HttpCode.NOT_FOUND, "Domain not found") ); } if (existingDomain.type !== "wildcard") { return next( createHttpError( HttpCode.BAD_REQUEST, "Domain settings can only be updated for wildcard domains" ) ); } const updateData: Partial<{ certResolver: string | null; preferWildcardCert: boolean; }> = {}; if (certResolver !== undefined) { updateData.certResolver = certResolver; } if (preferWildcardCert !== undefined && preferWildcardCert !== null) { updateData.preferWildcardCert = preferWildcardCert; } const [updatedDomain] = await db .update(domains) .set(updateData) .where(eq(domains.domainId, domainId)) .returning(); if (!updatedDomain) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to update domain" ) ); } return response(res, { data: { domainId: updatedDomain.domainId, certResolver: updatedDomain.certResolver, preferWildcardCert: updatedDomain.preferWildcardCert }, success: true, error: false, message: "Domain updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/external.ts ================================================ import { Router } from "express"; import config from "@server/lib/config"; import * as site from "./site"; import * as org from "./org"; import * as resource from "./resource"; import * as domain from "./domain"; import * as target from "./target"; import * as user from "./user"; import * as auth from "./auth"; import * as role from "./role"; import * as client from "./client"; import * as siteResource from "./siteResource"; import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; import * as idp from "./idp"; import * as blueprints from "./blueprints"; import * as apiKeys from "./apiKeys"; import * as logs from "./auditLogs"; import * as newt from "./newt"; import * as olm from "./olm"; import * as serverInfo from "./serverInfo"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, verifySessionMiddleware, verifySessionUserMiddleware, verifyOrgAccess, verifySiteAccess, verifyResourceAccess, verifyTargetAccess, verifyRoleAccess, verifySetResourceUsers, verifySetResourceClients, verifyUserAccess, getUserOrgs, verifyUserIsServerAdmin, verifyIsLoggedInUser, verifyClientAccess, verifyApiKeyAccess, verifyDomainAccess, verifyUserHasAction, verifyUserIsOrgOwner, verifySiteResourceAccess, verifyOlmAccess, verifyLimits } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; 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(); unauthenticated.get("/", (_, res) => { res.status(HttpCode.OK).json({ message: "Healthy" }); }); // Authenticated Root routes export const authenticated = Router(); authenticated.use(verifySessionUserMiddleware); authenticated.get("/pick-org-defaults", org.pickOrgDefaults); authenticated.get("/org/checkId", org.checkId); authenticated.put("/org", getUserOrgs, org.createOrg); authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); authenticated.get( "/org/:orgId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.getOrg), org.getOrg ); authenticated.post( "/org/:orgId", verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateOrg), logActionAudit(ActionsEnum.updateOrg), org.updateOrg ); authenticated.delete( "/org/:orgId", verifyOrgAccess, verifyUserIsOrgOwner, verifyUserHasAction(ActionsEnum.deleteOrg), logActionAudit(ActionsEnum.deleteOrg), org.deleteOrg ); authenticated.put( "/org/:orgId/site", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createSite), logActionAudit(ActionsEnum.createSite), site.createSite ); authenticated.get( "/org/:orgId/sites", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listSites), site.listSites ); authenticated.get( "/org/:orgId/site/:niceId", verifyOrgAccess, verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), site.getSite ); authenticated.get( "/org/:orgId/pick-site-defaults", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createSite), site.pickSiteDefaults ); authenticated.get( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), site.getSite ); authenticated.get( "/org/:orgId/pick-client-defaults", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), client.pickClientDefaults ); authenticated.get( "/org/:orgId/clients", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listClients), client.listClients ); authenticated.get( "/org/:orgId/user-devices", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listClients), client.listUserDevices ); authenticated.get( "/client/:clientId", verifyClientAccess, verifyUserHasAction(ActionsEnum.getClient), client.getClient ); authenticated.get( "/org/:orgId/client/:niceId", verifyOrgAccess, verifyClientAccess, verifyUserHasAction(ActionsEnum.getClient), client.getClient ); authenticated.put( "/org/:orgId/client", verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createClient), logActionAudit(ActionsEnum.createClient), client.createClient ); // TODO: Separate into a deleteUserClient (for user clients) and deleteClient (for machine clients) authenticated.delete( "/client/:clientId", verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), logActionAudit(ActionsEnum.deleteClient), client.deleteClient ); authenticated.post( "/client/:clientId/archive", verifyClientAccess, verifyLimits, verifyUserHasAction(ActionsEnum.archiveClient), logActionAudit(ActionsEnum.archiveClient), client.archiveClient ); authenticated.post( "/client/:clientId/unarchive", verifyClientAccess, verifyLimits, verifyUserHasAction(ActionsEnum.unarchiveClient), logActionAudit(ActionsEnum.unarchiveClient), client.unarchiveClient ); authenticated.post( "/client/:clientId/block", verifyClientAccess, verifyLimits, verifyUserHasAction(ActionsEnum.blockClient), logActionAudit(ActionsEnum.blockClient), client.blockClient ); authenticated.post( "/client/:clientId/unblock", verifyClientAccess, verifyLimits, verifyUserHasAction(ActionsEnum.unblockClient), logActionAudit(ActionsEnum.unblockClient), client.unblockClient ); authenticated.post( "/client/:clientId", verifyClientAccess, // this will check if the user has access to the client verifyLimits, verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client logActionAudit(ActionsEnum.updateClient), client.updateClient ); // authenticated.get( // "/site/:siteId/roles", // verifySiteAccess, // verifyUserHasAction(ActionsEnum.listSiteRoles), // site.listSiteRoles // ); authenticated.post( "/site/:siteId", verifySiteAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateSite), logActionAudit(ActionsEnum.updateSite), site.updateSite ); authenticated.delete( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.deleteSite), logActionAudit(ActionsEnum.deleteSite), site.deleteSite ); // TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" authenticated.get( "/site/:siteId/docker/status", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), site.dockerStatus ); authenticated.get( "/site/:siteId/docker/online", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), site.dockerOnline ); authenticated.post( "/site/:siteId/docker/check", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), site.checkDockerSocket ); authenticated.post( "/site/:siteId/docker/trigger", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), site.triggerFetchContainers ); authenticated.get( "/site/:siteId/docker/containers", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), site.listContainers ); // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createSiteResource), logActionAudit(ActionsEnum.createSiteResource), siteResource.createSiteResource ); authenticated.get( "/org/:orgId/site/:siteId/resources", verifyOrgAccess, verifySiteAccess, verifyUserHasAction(ActionsEnum.listSiteResources), siteResource.listSiteResources ); authenticated.get( "/org/:orgId/site-resources", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listSiteResources), siteResource.listAllSiteResourcesByOrg ); authenticated.get( "/site-resource/:siteResourceId", verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.getSiteResource), siteResource.getSiteResource ); authenticated.post( "/site-resource/:siteResourceId", verifySiteResourceAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateSiteResource), logActionAudit(ActionsEnum.updateSiteResource), siteResource.updateSiteResource ); authenticated.delete( "/site-resource/:siteResourceId", verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource), siteResource.deleteSiteResource ); authenticated.get( "/site-resource/:siteResourceId/roles", verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.listResourceRoles), siteResource.listSiteResourceRoles ); authenticated.get( "/site-resource/:siteResourceId/users", verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.listResourceUsers), siteResource.listSiteResourceUsers ); authenticated.get( "/site-resource/:siteResourceId/clients", verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.listResourceUsers), siteResource.listSiteResourceClients ); authenticated.post( "/site-resource/:siteResourceId/roles", verifySiteResourceAccess, verifyRoleAccess, verifyLimits, verifyUserHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), siteResource.setSiteResourceRoles ); authenticated.post( "/site-resource/:siteResourceId/users", verifySiteResourceAccess, verifySetResourceUsers, verifyLimits, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.setSiteResourceUsers ); authenticated.post( "/site-resource/:siteResourceId/clients", verifySiteResourceAccess, verifySetResourceClients, verifyLimits, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.setSiteResourceClients ); authenticated.post( "/site-resource/:siteResourceId/clients/add", verifySiteResourceAccess, verifySetResourceClients, verifyLimits, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.addClientToSiteResource ); authenticated.post( "/site-resource/:siteResourceId/clients/remove", verifySiteResourceAccess, verifySetResourceClients, verifyLimits, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.removeClientFromSiteResource ); authenticated.put( "/org/:orgId/resource", verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createResource), logActionAudit(ActionsEnum.createResource), resource.createResource ); authenticated.get( "/site/:siteId/resources", verifyUserHasAction(ActionsEnum.listResources), resource.listResources ); authenticated.get( "/org/:orgId/resources", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listResources), resource.listResources ); authenticated.get( "/org/:orgId/resource-names", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listResources), resource.listAllResourceNames ); authenticated.get( "/org/:orgId/user-resources", verifyOrgAccess, resource.getUserResources ); authenticated.get( "/org/:orgId/domains", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listOrgDomains), domain.listDomains ); authenticated.get( "/org/:orgId/domain/:domainId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.getDomain), domain.getDomain ); authenticated.patch( "/org/:orgId/domain/:domainId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrgDomain), domain.updateOrgDomain ); authenticated.get( "/org/:orgId/domain/:domainId/dns-records", verifyOrgAccess, verifyUserHasAction(ActionsEnum.getDNSRecords), domain.getDNSRecords ); authenticated.get( "/org/:orgId/invitations", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listInvitations), user.listInvitations ); authenticated.delete( "/org/:orgId/invitations/:inviteId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), logActionAudit(ActionsEnum.removeInvitation), user.removeInvitation ); authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, verifyUserHasAction(ActionsEnum.inviteUser), logActionAudit(ActionsEnum.inviteUser), user.inviteUser ); // maybe make this /invite/create instead unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated authenticated.get( "/resource/:resourceId/roles", verifyResourceAccess, verifyUserHasAction(ActionsEnum.listResourceRoles), resource.listResourceRoles ); authenticated.get( "/resource/:resourceId/users", verifyResourceAccess, verifyUserHasAction(ActionsEnum.listResourceUsers), resource.listResourceUsers ); authenticated.get( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.getResource), resource.getResource ); authenticated.get( "/org/:orgId/resource/:niceId", verifyOrgAccess, verifyResourceAccess, verifyUserHasAction(ActionsEnum.getResource), resource.getResource ); authenticated.post( "/resource/:resourceId", verifyResourceAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateResource), logActionAudit(ActionsEnum.updateResource), resource.updateResource ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResource), logActionAudit(ActionsEnum.deleteResource), resource.deleteResource ); authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), target.createTarget ); authenticated.get( "/resource/:resourceId/targets", verifyResourceAccess, verifyUserHasAction(ActionsEnum.listTargets), target.listTargets ); authenticated.put( "/resource/:resourceId/rule", verifyResourceAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createResourceRule), logActionAudit(ActionsEnum.createResourceRule), resource.createResourceRule ); authenticated.get( "/resource/:resourceId/rules", verifyResourceAccess, verifyUserHasAction(ActionsEnum.listResourceRules), resource.listResourceRules ); authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateResourceRule), logActionAudit(ActionsEnum.updateResourceRule), resource.updateResourceRule ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), logActionAudit(ActionsEnum.deleteResourceRule), resource.deleteResourceRule ); authenticated.get( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.getTarget), target.getTarget ); authenticated.post( "/target/:targetId", verifyTargetAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateTarget), logActionAudit(ActionsEnum.updateTarget), target.updateTarget ); authenticated.delete( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.deleteTarget), logActionAudit(ActionsEnum.deleteTarget), target.deleteTarget ); authenticated.put( "/org/:orgId/role", verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createRole), logActionAudit(ActionsEnum.createRole), role.createRole ); authenticated.get( "/org/:orgId/roles", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listRoles), role.listRoles ); authenticated.post( "/role/:roleId", verifyRoleAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateRole), logActionAudit(ActionsEnum.updateRole), role.updateRole ); // authenticated.get( // "/role/:roleId", // verifyRoleAccess, // verifyUserInRole, // verifyUserHasAction(ActionsEnum.getRole), // role.getRole // ); // authenticated.post( // "/role/:roleId", // verifyRoleAccess, // verifyUserHasAction(ActionsEnum.updateRole), // role.updateRole // ); authenticated.delete( "/role/:roleId", verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), logActionAudit(ActionsEnum.deleteRole), role.deleteRole ); authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, verifyUserAccess, verifyLimits, verifyUserHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), user.addUserRole ); authenticated.post( "/resource/:resourceId/roles", verifyResourceAccess, verifyRoleAccess, verifyLimits, verifyUserHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), resource.setResourceRoles ); authenticated.post( "/resource/:resourceId/users", verifyResourceAccess, verifySetResourceUsers, verifyLimits, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), resource.setResourceUsers ); authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, verifyLimits, verifyUserHasAction(ActionsEnum.setResourcePassword), logActionAudit(ActionsEnum.setResourcePassword), resource.setResourcePassword ); authenticated.post( `/resource/:resourceId/pincode`, verifyResourceAccess, verifyLimits, verifyUserHasAction(ActionsEnum.setResourcePincode), logActionAudit(ActionsEnum.setResourcePincode), resource.setResourcePincode ); authenticated.post( `/resource/:resourceId/header-auth`, verifyResourceAccess, verifyLimits, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), logActionAudit(ActionsEnum.setResourceHeaderAuth), resource.setResourceHeaderAuth ); authenticated.post( `/resource/:resourceId/whitelist`, verifyResourceAccess, verifyLimits, verifyUserHasAction(ActionsEnum.setResourceWhitelist), logActionAudit(ActionsEnum.setResourceWhitelist), resource.setResourceWhitelist ); authenticated.get( `/resource/:resourceId/whitelist`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.getResourceWhitelist), resource.getResourceWhitelist ); authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, verifyLimits, verifyUserHasAction(ActionsEnum.generateAccessToken), logActionAudit(ActionsEnum.generateAccessToken), accessToken.generateAccessToken ); authenticated.delete( `/access-token/:accessTokenId`, verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), logActionAudit(ActionsEnum.deleteAcessToken), accessToken.deleteAccessToken ); authenticated.get( `/org/:orgId/access-tokens`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listAccessTokens), accessToken.listAccessTokens ); authenticated.get( `/resource/:resourceId/access-tokens`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.listAccessTokens), accessToken.listAccessTokens ); authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview); authenticated.get(`/server-info`, serverInfo.getServerInfo); authenticated.post( `/supporter-key/validate`, supporterKey.validateSupporterKey ); authenticated.post(`/supporter-key/hide`, supporterKey.hideSupporterKey); unauthenticated.get( "/resource/:resourceGuid/auth", resource.getResourceAuthInfo ); // authenticated.get( // "/role/:roleId/resources", // verifyRoleAccess, // verifyUserInRole, // verifyUserHasAction(ActionsEnum.listRoleResources), // role.listRoleResources // ); // authenticated.put( // "/role/:roleId/action", // verifyRoleAccess, // verifyUserInRole, // verifyUserHasAction(ActionsEnum.addRoleAction), // role.addRoleAction // ); // authenticated.delete( // "/role/:roleId/action", // verifyRoleAccess, // verifyUserInRole, // verifyUserHasAction(ActionsEnum.removeRoleAction), // role.removeRoleAction // ); // authenticated.get( // "/role/:roleId/actions", // verifyRoleAccess, // verifyUserInRole, // verifyUserHasAction(ActionsEnum.listRoleActions), // role.listRoleActions // ); unauthenticated.get("/user", verifySessionMiddleware, user.getUser); unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice); authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser); authenticated.post( "/user/:userId/generate-password-reset-code", verifyUserIsServerAdmin, user.adminGeneratePasswordResetCode ); authenticated.delete( "/user/:userId", verifyUserIsServerAdmin, user.adminRemoveUser ); authenticated.put( "/org/:orgId/user", verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createOrgUser), logActionAudit(ActionsEnum.createOrgUser), user.createOrgUser ); authenticated.post( "/org/:orgId/user/:userId", verifyOrgAccess, verifyUserAccess, verifyLimits, verifyUserHasAction(ActionsEnum.updateOrgUser), logActionAudit(ActionsEnum.updateOrgUser), user.updateOrgUser ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); authenticated.get("/org/:orgId/user/:userId/check", org.checkOrgUserAccess); authenticated.post( "/user/:userId/2fa", verifyUserIsServerAdmin, user.updateUser2FA ); authenticated.get( "/org/:orgId/users", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listUsers), user.listUsers ); authenticated.delete( "/org/:orgId/user/:userId", verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), logActionAudit(ActionsEnum.removeUser), user.removeUserOrg ); // authenticated.put( // "/user/:userId/site", // verifySiteAccess, // verifyUserAccess, // verifyUserHasAction(ActionsEnum.addRoleSite), // role.addRoleSite // ); // authenticated.delete( // "/user/:userId/site", // verifySiteAccess, // verifyUserAccess, // verifyUserHasAction(ActionsEnum.removeRoleSite), // role.removeRoleSite // ); // authenticated.put( // "/org/:orgId/user/:userId/action", // verifyOrgAccess, // verifyUserAccess, // verifyUserHasAction(ActionsEnum.addRoleAction), // role.addRoleAction // ); // authenticated.delete( // "/org/:orgId/user/:userId/action", // verifyOrgAccess, // verifyUserAccess, // verifyUserHasAction(ActionsEnum.removeRoleAction), // role.removeRoleAction // ); // authenticated.put( // "/newt", // verifyUserHasAction(ActionsEnum.createNewt), // createNewt // ); authenticated.put("/user/:userId/olm", verifyIsLoggedInUser, olm.createUserOlm); authenticated.get("/user/:userId/olms", verifyIsLoggedInUser, olm.listUserOlms); authenticated.post( "/user/:userId/olm/:olmId/archive", verifyIsLoggedInUser, verifyOlmAccess, verifyLimits, olm.archiveUserOlm ); authenticated.post( "/user/:userId/olm/:olmId/unarchive", verifyIsLoggedInUser, verifyOlmAccess, olm.unarchiveUserOlm ); authenticated.get( "/user/:userId/olm/:olmId", verifyIsLoggedInUser, verifyOlmAccess, olm.getUserOlm ); authenticated.post( "/user/:userId/olm/recover", verifyIsLoggedInUser, olm.recoverOlmWithFingerprint ); authenticated.put( "/idp/oidc", verifyUserIsServerAdmin, // verifyUserHasAction(ActionsEnum.createIdp), idp.createOidcIdp ); authenticated.post( "/idp/:idpId/oidc", verifyUserIsServerAdmin, idp.updateOidcIdp ); authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp); authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); authenticated.put( "/idp/:idpId/org/:orgId", verifyUserIsServerAdmin, idp.createIdpOrgPolicy ); authenticated.post( "/idp/:idpId/org/:orgId", verifyUserIsServerAdmin, idp.updateIdpOrgPolicy ); authenticated.delete( "/idp/:idpId/org/:orgId", verifyUserIsServerAdmin, idp.deleteIdpOrgPolicy ); authenticated.get( "/idp/:idpId/org", verifyUserIsServerAdmin, idp.listIdpOrgPolicies ); authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); authenticated.get( `/api-key/:apiKeyId`, verifyUserIsServerAdmin, apiKeys.getApiKey ); authenticated.put( `/api-key`, verifyUserIsServerAdmin, apiKeys.createRootApiKey ); authenticated.delete( `/api-key/:apiKeyId`, verifyUserIsServerAdmin, apiKeys.deleteApiKey ); authenticated.get( `/api-keys`, verifyUserIsServerAdmin, apiKeys.listRootApiKeys ); authenticated.get( `/api-key/:apiKeyId/actions`, verifyUserIsServerAdmin, apiKeys.listApiKeyActions ); authenticated.post( `/api-key/:apiKeyId/actions`, verifyUserIsServerAdmin, apiKeys.setApiKeyActions ); authenticated.get( `/org/:orgId/api-keys`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listApiKeys), apiKeys.listOrgApiKeys ); authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, verifyOrgAccess, verifyApiKeyAccess, verifyLimits, verifyUserHasAction(ActionsEnum.setApiKeyActions), logActionAudit(ActionsEnum.setApiKeyActions), apiKeys.setApiKeyActions ); authenticated.get( `/org/:orgId/api-key/:apiKeyId/actions`, verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.listApiKeyActions), apiKeys.listApiKeyActions ); authenticated.put( `/org/:orgId/api-key`, verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createApiKey), logActionAudit(ActionsEnum.createApiKey), apiKeys.createOrgApiKey ); authenticated.delete( `/org/:orgId/api-key/:apiKeyId`, verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), logActionAudit(ActionsEnum.deleteApiKey), apiKeys.deleteOrgApiKey ); authenticated.get( `/org/:orgId/api-key/:apiKeyId`, verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.getApiKey), apiKeys.getApiKey ); authenticated.put( `/org/:orgId/domain`, verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.createOrgDomain), logActionAudit(ActionsEnum.createOrgDomain), domain.createOrgDomain ); authenticated.post( `/org/:orgId/domain/:domainId/restart`, verifyOrgAccess, verifyDomainAccess, verifyLimits, verifyUserHasAction(ActionsEnum.restartOrgDomain), logActionAudit(ActionsEnum.restartOrgDomain), domain.restartOrgDomain ); authenticated.delete( `/org/:orgId/domain/:domainId`, verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), logActionAudit(ActionsEnum.deleteOrgDomain), domain.deleteAccountDomain ); authenticated.get( "/org/:orgId/logs/request", verifyOrgAccess, verifyUserHasAction(ActionsEnum.viewLogs), logs.queryRequestAuditLogs ); authenticated.get( "/org/:orgId/logs/analytics", verifyOrgAccess, verifyUserHasAction(ActionsEnum.viewLogs), logs.queryRequestAnalytics ); authenticated.get( "/org/:orgId/logs/request/export", verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), logs.exportRequestAuditLogs ); authenticated.get( "/org/:orgId/blueprints", verifyOrgAccess, verifyUserHasAction(ActionsEnum.listBlueprints), blueprints.listBlueprints ); authenticated.put( "/org/:orgId/blueprint", verifyOrgAccess, verifyLimits, verifyUserHasAction(ActionsEnum.applyBlueprint), blueprints.applyYAMLBlueprint ); authenticated.get( "/org/:orgId/blueprint/:blueprintId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.getBlueprint), blueprints.getBlueprint ); authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage); // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); authRouter.use( rateLimit({ windowMs: config.getRawConfig().rate_limits.auth.window_minutes, max: config.getRawConfig().rate_limits.auth.max_requests, keyGenerator: (req) => `authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`, handler: (req, res, next) => { const message = `Rate limit exceeded. You can make ${config.getRawConfig().rate_limits.auth.max_requests} requests every ${config.getRawConfig().rate_limits.auth.window_minutes} minute(s).`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }) ); authRouter.put( "/signup", rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => `signup:${ipKeyGenerator(req.ip || "")}:${req.body.email}`, handler: (req, res, next) => { const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.signup ); authRouter.post( "/login", rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => `login:${req.body.email || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only log in ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.login ); authRouter.post("/logout", auth.logout); authRouter.post("/delete-my-account", auth.deleteMyAccount); authRouter.post( "/lookup-user", rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => `lookupUser:${req.body.identifier || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only lookup users ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.lookupUser ); authRouter.post( "/newt/get-token", rateLimit({ windowMs: 15 * 60 * 1000, max: 900, keyGenerator: (req) => `newtGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only request a Newt token ${900} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), newt.getNewtToken ); authRouter.post( "/olm/get-token", rateLimit({ windowMs: 15 * 60 * 1000, max: 900, keyGenerator: (req) => `olmGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), olm.getOlmToken ); authRouter.post( "/2fa/enable", rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => { return `signup:${req.body.email || req.user?.userId || ipKeyGenerator(req.ip || "")}`; }, handler: (req, res, next) => { const message = `You can only enable 2FA ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.verifyTotp ); authRouter.post( "/2fa/request", rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => { return `signup:${req.body.email || req.user?.userId || ipKeyGenerator(req.ip || "")}`; }, handler: (req, res, next) => { const message = `You can only request a 2FA code ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.requestTotpSecret ); authRouter.post( "/2fa/disable", verifySessionUserMiddleware, rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => `signup:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only disable 2FA ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.disable2fa ); authRouter.post( "/verify-email", rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => `signup:${req.body.email || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), verifySessionMiddleware, auth.verifyEmail ); authRouter.post( "/verify-email/request", verifySessionMiddleware, rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => `requestEmailVerificationCode:${req.user?.email || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.requestEmailVerificationCode ); authRouter.post( "/change-password", verifySessionUserMiddleware, auth.changePassword ); authRouter.post( "/reset-password/request", rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => `requestPasswordReset:${req.body.email || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.requestPasswordReset ); authRouter.post( "/reset-password/", rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => `resetPassword:${req.body.email || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.resetPassword ); authRouter.post( "/resource/:resourceId/password", rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => `authWithPassword:${ipKeyGenerator(req.ip || "")}:${req.params.resourceId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only authenticate with password ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), resource.authWithPassword ); authRouter.post( "/resource/:resourceId/pincode", rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => `authWithPincode:${ipKeyGenerator(req.ip || "")}:${req.params.resourceId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only authenticate with pincode ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), resource.authWithPincode ); authRouter.post( "/resource/:resourceId/whitelist", rateLimit({ windowMs: 15 * 60 * 1000, max: 15, keyGenerator: (req) => `authWithWhitelist:${ipKeyGenerator(req.ip || "")}:${req.body.email}:${req.params.resourceId}`, handler: (req, res, next) => { const message = `You can only request an email OTP ${15} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), resource.authWithWhitelist ); authRouter.post( "/resource/:resourceId/access-token", resource.authWithAccessToken ); authRouter.post("/access-token", resource.authWithAccessToken); authRouter.post("/idp/:idpId/oidc/generate-url", idp.generateOidcUrl); authRouter.post("/idp/:idpId/oidc/validate-callback", idp.validateOidcCallback); authRouter.put("/set-server-admin", auth.setServerAdmin); authRouter.get("/initial-setup-complete", auth.initialSetupComplete); authRouter.post("/validate-setup-token", auth.validateSetupToken); // Security Key routes authRouter.post( "/security-key/register/start", verifySessionUserMiddleware, rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // Allow 5 security key registrations per 15 minutes keyGenerator: (req) => `securityKeyRegister:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only register a security key ${5} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.startRegistration ); authRouter.post( "/security-key/register/verify", verifySessionUserMiddleware, auth.verifyRegistration ); authRouter.post( "/security-key/authenticate/start", rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 10, // Allow 10 authentication attempts per 15 minutes per IP keyGenerator: (req) => { return `securityKeyAuth:${req.body.email || ipKeyGenerator(req.ip || "")}`; }, handler: (req, res, next) => { const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.startAuthentication ); authRouter.post("/security-key/authenticate/verify", auth.verifyAuthentication); authRouter.get( "/security-key/list", verifySessionUserMiddleware, auth.listSecurityKeys ); authRouter.delete( "/security-key/:credentialId", verifySessionUserMiddleware, rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 20, // Allow 10 authentication attempts per 15 minutes per IP keyGenerator: (req) => `securityKeyAuth:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only delete a security key ${10} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.deleteSecurityKey ); authRouter.post( "/device-web-auth/start", rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 30, // Allow 30 device auth code requests per 15 minutes per IP keyGenerator: (req) => `deviceWebAuthStart:${ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only request a device auth code ${30} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.startDeviceWebAuth ); authRouter.get( "/device-web-auth/poll/:code", rateLimit({ windowMs: 60 * 1000, // 1 minute max: 60, // Allow 60 polling requests per minute per IP (poll every second) keyGenerator: (req) => `deviceWebAuthPoll:${ipKeyGenerator(req.ip || "")}:${req.params.code}`, handler: (req, res, next) => { const message = `You can only poll a device auth code ${60} times per minute. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.pollDeviceWebAuth ); authenticated.post( "/device-web-auth/verify", rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 50, // Allow 50 verification attempts per 15 minutes per user keyGenerator: (req) => `deviceWebAuthVerify:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, handler: (req, res, next) => { const message = `You can only verify a device auth code ${50} times every ${15} minutes. Please try again later.`; return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); }, store: createStore() }), auth.verifyDeviceWebAuth ); ================================================ FILE: server/routers/generatedLicense/types.ts ================================================ export type GeneratedLicenseKey = { instanceName: string | null; licenseKey: string; expiresAt: string; isValid: boolean; createdAt: string; tier: string; type: string; users: number; sites: number; }; export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[]; export type NewLicenseKey = { licenseKey: { id: number; instanceName: string | null; instanceId: string; licenseKey: string; tier: string; type: string; quantity: number; quantity_2: number; isValid: boolean; updatedAt: string; createdAt: string; expiresAt: string; orgId: string; }; }; export type GenerateNewLicenseResponse = NewLicenseKey; ================================================ FILE: server/routers/gerbil/createExitNode.ts ================================================ import { db, ExitNode, exitNodes } from "@server/db"; import { getUniqueExitNodeEndpointName } from "@server/db/names"; import config from "@server/lib/config"; import { getNextAvailableSubnet } from "@server/lib/exitNodes"; import logger from "@server/logger"; import { eq } from "drizzle-orm"; export async function createExitNode( publicKey: string, reachableAt: string | undefined ) { // Fetch exit node const [exitNodeQuery] = await db.select().from(exitNodes).limit(1); let exitNode: ExitNode; if (!exitNodeQuery) { const address = await getNextAvailableSubnet(); // TODO: eventually we will want to get the next available port so that we can multiple exit nodes // const listenPort = await getNextAvailablePort(); const listenPort = config.getRawConfig().gerbil.start_port; let subEndpoint = ""; if (config.getRawConfig().gerbil.use_subdomain) { subEndpoint = await getUniqueExitNodeEndpointName(); } const exitNodeName = config.getRawConfig().gerbil.exit_node_name || `Exit Node ${publicKey.slice(0, 8)}`; // create a new exit node [exitNode] = await db .insert(exitNodes) .values({ publicKey, endpoint: `${subEndpoint}${subEndpoint != "" ? "." : ""}${config.getRawConfig().gerbil.base_endpoint}`, address, online: true, listenPort, reachableAt, name: exitNodeName }) .returning() .execute(); logger.info( `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` ); } else { // update the existing exit node [exitNode] = await db .update(exitNodes) .set({ reachableAt, publicKey, online: true }) .where(eq(exitNodes.publicKey, publicKey)) .returning(); logger.info(`Updated exit node with reachableAt to ${reachableAt}`); } return exitNode; } ================================================ FILE: server/routers/gerbil/getAllRelays.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { clients, exitNodes, newts, olms, Site, sites, clientSitesAssociationsCache, ExitNode } from "@server/db"; import { db } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; // Define Zod schema for request validation const getAllRelaysSchema = z.object({ publicKey: z.string().optional() }); // Type for peer destination interface PeerDestination { destinationIP: string; destinationPort: number; } // Updated mappings type to support multiple destinations per endpoint interface ProxyMapping { destinations: PeerDestination[]; } export async function getAllRelays( req: Request, res: Response, next: NextFunction ): Promise { try { // Validate request parameters const parsedParams = getAllRelaysSchema.safeParse(req.body); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { publicKey } = parsedParams.data; if (!publicKey) { return next( createHttpError(HttpCode.BAD_REQUEST, "publicKey is required") ); } // Fetch exit node const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.publicKey, publicKey)); if (!exitNode) { return next( createHttpError(HttpCode.NOT_FOUND, "Exit node not found") ); } const mappings = await generateRelayMappings(exitNode); logger.debug( `Returning mappings for ${Object.keys(mappings).length} endpoints` ); return res.status(HttpCode.OK).send({ mappings }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } export async function generateRelayMappings(exitNode: ExitNode) { // Fetch sites for this exit node const sitesRes = await db .select() .from(sites) .where(eq(sites.exitNodeId, exitNode.exitNodeId)); if (sitesRes.length === 0) { return {}; } // Initialize mappings object for multi-peer support const mappings: { [key: string]: ProxyMapping } = {}; // Process each site for (const site of sitesRes) { if (!site.endpoint || !site.subnet || !site.listenPort) { continue; } // Find all clients associated with this site through clientSites const clientSitesRes = await db .select() .from(clientSitesAssociationsCache) .where(eq(clientSitesAssociationsCache.siteId, site.siteId)); for (const clientSite of clientSitesRes) { if (!clientSite.endpoint) { continue; } // Add this site as a destination for the client if (!mappings[clientSite.endpoint]) { mappings[clientSite.endpoint] = { destinations: [] }; } // Add site as a destination for this client const destination: PeerDestination = { destinationIP: site.subnet.split("/")[0], destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }; // Check if this destination is already in the array to avoid duplicates const isDuplicate = mappings[clientSite.endpoint].destinations.some( (dest) => dest.destinationIP === destination.destinationIP && dest.destinationPort === destination.destinationPort ); if (!isDuplicate) { mappings[clientSite.endpoint].destinations.push(destination); } } // Also handle site-to-site communication (all sites in the same org) if (site.orgId) { const orgSites = await db .select() .from(sites) .where(eq(sites.orgId, site.orgId)); for (const peer of orgSites) { // Skip self if ( peer.siteId === site.siteId || !peer.endpoint || !peer.subnet || !peer.listenPort ) { continue; } // Add peer site as a destination for this site if (!mappings[site.endpoint]) { mappings[site.endpoint] = { destinations: [] }; } const destination: PeerDestination = { destinationIP: peer.subnet.split("/")[0], destinationPort: peer.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }; // Check for duplicates const isDuplicate = mappings[site.endpoint].destinations.some( (dest) => dest.destinationIP === destination.destinationIP && dest.destinationPort === destination.destinationPort ); if (!isDuplicate) { mappings[site.endpoint].destinations.push(destination); } } } } return mappings; } ================================================ FILE: server/routers/gerbil/getConfig.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { sites, exitNodes, ExitNode } from "@server/db"; import { db } from "@server/db"; import { eq, isNotNull, and } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import config from "@server/lib/config"; import { fromError } from "zod-validation-error"; import { getAllowedIps } from "../target/helpers"; import { createExitNode } from "#dynamic/routers/gerbil/createExitNode"; // Define Zod schema for request validation const getConfigSchema = z.object({ publicKey: z.string(), reachableAt: z.string().optional() }); export type GetConfigResponse = { listenPort: number; ipAddress: string; peers: { publicKey: string | null; allowedIps: string[]; }[]; }; export async function getConfig( req: Request, res: Response, next: NextFunction ): Promise { try { // Validate request parameters const parsedParams = getConfigSchema.safeParse(req.body); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { publicKey, reachableAt } = parsedParams.data; if (!publicKey) { return next( createHttpError(HttpCode.BAD_REQUEST, "publicKey is required") ); } // clean up the public key - keep only valid base64 characters (A-Z, a-z, 0-9, +, /, =) const cleanedPublicKey = publicKey.replace(/[^A-Za-z0-9+/=]/g, ""); const exitNode = await createExitNode(cleanedPublicKey, reachableAt); if (!exitNode) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create exit node" ) ); } const configResponse = await generateGerbilConfig(exitNode); logger.debug("Sending config: ", configResponse); return res.status(HttpCode.OK).send(configResponse); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } export async function generateGerbilConfig(exitNode: ExitNode) { const sitesRes = await db .select() .from(sites) .where( and( eq(sites.exitNodeId, exitNode.exitNodeId), isNotNull(sites.pubKey), isNotNull(sites.subnet) ) ); const peers = await Promise.all( sitesRes.map(async (site) => { if (site.type === "wireguard") { return { publicKey: site.pubKey, allowedIps: await getAllowedIps(site.siteId) }; } else if (site.type === "newt") { return { publicKey: site.pubKey, allowedIps: [site.subnet!] }; } return { publicKey: null, allowedIps: [] }; }) ); const configResponse: GetConfigResponse = { listenPort: exitNode.listenPort || 51820, ipAddress: exitNode.address, peers }; return configResponse; } ================================================ FILE: server/routers/gerbil/getResolvedHostname.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { resolveExitNodes } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; import config from "@server/lib/config"; // Define Zod schema for request validation const getResolvedHostnameSchema = z.object({ hostname: z.string(), publicKey: z.string() }); export async function getResolvedHostname( req: Request, res: Response, next: NextFunction ): Promise { try { let endpoints: string[] = []; // always route locally if (build != "oss") { // Validate request parameters const parsedParams = getResolvedHostnameSchema.safeParse(req.body); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { hostname, publicKey } = parsedParams.data; const dashboardUrl = config.getRawConfig().app.dashboard_url; // extract the domain removing the http and stuff const baseDomain = dashboardUrl ? dashboardUrl .replace("http://", "") .replace("https://", "") .split("/")[0] : null; // if the hostname ends with the base domain then send back a empty array if (baseDomain && hostname.endsWith(baseDomain)) { return res.status(HttpCode.OK).send({ endpoints: [] // this should force to route locally }); } const resourceExitNodes = await resolveExitNodes( hostname, publicKey ); if (resourceExitNodes.length === 0) { // no exit nodes found, return empty array to force local routing return res.status(HttpCode.OK).send({ endpoints: [] // this should force to route locally }); } endpoints = resourceExitNodes.map((node) => node.endpoint); } // return the endpoints return res.status(HttpCode.OK).send({ endpoints }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } ================================================ FILE: server/routers/gerbil/index.ts ================================================ export * from "./getConfig"; export * from "./receiveBandwidth"; export * from "./updateHolePunch"; export * from "./getAllRelays"; export * from "./getResolvedHostname"; ================================================ FILE: server/routers/gerbil/peers.ts ================================================ import logger from "@server/logger"; import { db } from "@server/db"; import { exitNodes } from "@server/db"; import { eq } from "drizzle-orm"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; export async function addPeer( exitNodeId: number, peer: { publicKey: string; allowedIps: string[]; } ) { logger.info( `Adding peer with public key ${peer.publicKey} to exit node ${exitNodeId}` ); const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, exitNodeId)) .limit(1); if (!exitNode) { throw new Error(`Exit node with ID ${exitNodeId} not found`); } return await sendToExitNode(exitNode, { remoteType: "remoteExitNode/peers/add", localPath: "/peer", method: "POST", data: peer }); } export async function deletePeer(exitNodeId: number, publicKey: string) { logger.info( `Deleting peer with public key ${publicKey} from exit node ${exitNodeId}` ); const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, exitNodeId)) .limit(1); if (!exitNode) { throw new Error(`Exit node with ID ${exitNodeId} not found`); } return await sendToExitNode(exitNode, { remoteType: "remoteExitNode/peers/remove", localPath: "/peer", method: "DELETE", data: { publicKey: publicKey }, queryParams: { public_key: publicKey } }); } ================================================ FILE: server/routers/gerbil/receiveBandwidth.ts ================================================ import { Request, Response, NextFunction } from "express"; import { eq, sql } from "drizzle-orm"; import { sites } from "@server/db"; import { db } from "@server/db"; import logger from "@server/logger"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing/features"; import { checkExitNodeOrg } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; interface PeerBandwidth { publicKey: string; bytesIn: number; bytesOut: number; } interface AccumulatorEntry { bytesIn: number; bytesOut: number; /** Present when the update came through a remote exit node. */ exitNodeId?: number; /** Whether to record egress usage for billing purposes. */ calcUsage: boolean; } // Retry configuration for deadlock handling const MAX_RETRIES = 3; const BASE_DELAY_MS = 50; // How often to flush accumulated bandwidth data to the database const FLUSH_INTERVAL_MS = 30_000; // 30 seconds // In-memory accumulator: publicKey -> AccumulatorEntry let accumulator = new Map(); /** * Check if an error is a deadlock error */ function isDeadlockError(error: any): boolean { return ( error?.code === "40P01" || error?.cause?.code === "40P01" || (error?.message && error.message.includes("deadlock")) ); } /** * Execute a function with retry logic for deadlock handling */ async function withDeadlockRetry( operation: () => Promise, context: string ): Promise { let attempt = 0; while (true) { try { return await operation(); } catch (error: any) { if (isDeadlockError(error) && attempt < MAX_RETRIES) { attempt++; const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS; const jitter = Math.random() * baseDelay; const delay = baseDelay + jitter; logger.warn( `Deadlock detected in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms` ); await new Promise((resolve) => setTimeout(resolve, delay)); continue; } throw error; } } } /** * Flush all accumulated site bandwidth data to the database. * * Swaps out the accumulator before writing so that any bandwidth messages * received during the flush are captured in the new accumulator rather than * being lost or causing contention. Entries that fail to write are re-queued * back into the accumulator so they will be retried on the next flush. * * This function is exported so that the application's graceful-shutdown * cleanup handler can call it before the process exits. */ export async function flushSiteBandwidthToDb(): Promise { if (accumulator.size === 0) { return; } // Atomically swap out the accumulator so new data keeps flowing in // while we write the snapshot to the database. const snapshot = accumulator; accumulator = new Map(); const currentTime = new Date().toISOString(); // Sort by publicKey for consistent lock ordering across concurrent // writers — deadlock-prevention strategy. const sortedEntries = [...snapshot.entries()].sort(([a], [b]) => a.localeCompare(b) ); logger.debug( `Flushing accumulated bandwidth data for ${sortedEntries.length} site(s) to the database` ); // Aggregate billing usage by org, collected during the DB update loop. const orgUsageMap = new Map(); for (const [publicKey, { bytesIn, bytesOut, exitNodeId, calcUsage }] of sortedEntries) { try { const updatedSite = await withDeadlockRetry(async () => { const [result] = await db .update(sites) .set({ megabytesOut: sql`COALESCE(${sites.megabytesOut}, 0) + ${bytesIn}`, megabytesIn: sql`COALESCE(${sites.megabytesIn}, 0) + ${bytesOut}`, lastBandwidthUpdate: currentTime, }) .where(eq(sites.pubKey, publicKey)) .returning({ orgId: sites.orgId, siteId: sites.siteId }); return result; }, `flush bandwidth for site ${publicKey}`); if (updatedSite) { if (exitNodeId) { const notAllowed = await checkExitNodeOrg( exitNodeId, updatedSite.orgId ); if (notAllowed) { logger.warn( `Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}` ); // Skip usage tracking for this site but continue // processing the rest. continue; } } if (calcUsage) { const totalBandwidth = bytesIn + bytesOut; const current = orgUsageMap.get(updatedSite.orgId) ?? 0; orgUsageMap.set(updatedSite.orgId, current + totalBandwidth); } } } catch (error) { logger.error( `Failed to flush bandwidth for site ${publicKey}:`, error ); // Re-queue the failed entry so it is retried on the next flush // rather than silently dropped. const existing = accumulator.get(publicKey); if (existing) { existing.bytesIn += bytesIn; existing.bytesOut += bytesOut; } else { accumulator.set(publicKey, { bytesIn, bytesOut, exitNodeId, calcUsage }); } } } // Process billing usage updates outside the site-update loop to keep // lock scope small and concerns separated. if (orgUsageMap.size > 0) { // Sort org IDs for consistent lock ordering. const sortedOrgIds = [...orgUsageMap.keys()].sort(); for (const orgId of sortedOrgIds) { try { const totalBandwidth = orgUsageMap.get(orgId)!; const bandwidthUsage = await usageService.add( orgId, FeatureId.EGRESS_DATA_MB, totalBandwidth ); if (bandwidthUsage) { // Fire-and-forget — don't block the flush on limit checking. usageService .checkLimitSet( orgId, FeatureId.EGRESS_DATA_MB, bandwidthUsage ) .catch((error: any) => { logger.error( `Error checking bandwidth limits for org ${orgId}:`, error ); }); } } catch (error) { logger.error( `Error processing usage for org ${orgId}:`, error ); // Continue with other orgs. } } } } // --------------------------------------------------------------------------- // Periodic flush timer // --------------------------------------------------------------------------- const flushTimer = setInterval(async () => { try { await flushSiteBandwidthToDb(); } catch (error) { logger.error( "Unexpected error during periodic site bandwidth flush:", error ); } }, FLUSH_INTERVAL_MS); // Allow the process to exit normally even while the timer is pending. // The graceful-shutdown path (see server/cleanup.ts) will call // flushSiteBandwidthToDb() explicitly before process.exit(), so no data // is lost. flushTimer.unref(); // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Accumulate bandwidth data reported by a gerbil or remote exit node. * * Only peers that actually transferred data (bytesIn > 0) are added to the * accumulator; peers with no activity are silently ignored, which means the * flush will only write rows that have genuinely changed. * * The function is intentionally synchronous in its fast path so that the * HTTP handler can respond immediately without waiting for any I/O. */ export async function updateSiteBandwidth( bandwidthData: PeerBandwidth[], calcUsageAndLimits: boolean, exitNodeId?: number ): Promise { for (const { publicKey, bytesIn, bytesOut } of bandwidthData) { // Skip peers that haven't transferred any data — writing zeros to the // database would be a no-op anyway. if (bytesIn <= 0 && bytesOut <= 0) { continue; } const existing = accumulator.get(publicKey); if (existing) { existing.bytesIn += bytesIn; existing.bytesOut += bytesOut; // Retain the most-recent exitNodeId for this peer. if (exitNodeId !== undefined) { existing.exitNodeId = exitNodeId; } // Once calcUsage has been requested for a peer, keep it set for // the lifetime of this flush window. if (calcUsageAndLimits) { existing.calcUsage = true; } } else { accumulator.set(publicKey, { bytesIn, bytesOut, exitNodeId, calcUsage: calcUsageAndLimits }); } } } // --------------------------------------------------------------------------- // HTTP handler // --------------------------------------------------------------------------- export const receiveBandwidth = async ( req: Request, res: Response, next: NextFunction ): Promise => { try { const bandwidthData: PeerBandwidth[] = req.body; if (!Array.isArray(bandwidthData)) { throw new Error("Invalid bandwidth data"); } // Accumulate in memory; the periodic timer (and the shutdown hook) // will write to the database. await updateSiteBandwidth(bandwidthData, build == "saas"); return response(res, { data: {}, success: true, error: false, message: "Bandwidth data updated successfully", status: HttpCode.OK }); } catch (error) { logger.error("Error updating bandwidth data:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } }; ================================================ FILE: server/routers/gerbil/updateHolePunch.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { clients, newts, olms, Site, sites, clientSitesAssociationsCache, exitNodes, ExitNode } from "@server/db"; import { db } from "@server/db"; import { eq, and } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import { checkExitNodeOrg } from "#dynamic/lib/exitNodes"; import { updatePeer as updateOlmPeer } from "../olm/peers"; import { updatePeer as updateNewtPeer } from "../newt/peers"; import { formatEndpoint } from "@server/lib/ip"; // Define Zod schema for request validation const updateHolePunchSchema = z.object({ olmId: z.string().optional(), newtId: z.string().optional(), token: z.string(), ip: z.string(), port: z.number(), timestamp: z.number(), publicKey: z.string(), reachableAt: z.string().optional(), exitNodePublicKey: z.string().optional() }); // New response type with multi-peer destination support interface PeerDestination { destinationIP: string; destinationPort: number; } export async function updateHolePunch( req: Request, res: Response, next: NextFunction ): Promise { try { // Validate request parameters const parsedParams = updateHolePunchSchema.safeParse(req.body); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { olmId, newtId, ip, port, timestamp, token, reachableAt, publicKey, // this is the client's current public key for this session exitNodePublicKey } = parsedParams.data; let exitNode: ExitNode | undefined; if (exitNodePublicKey) { // Get the exit node by public key [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.publicKey, exitNodePublicKey)); } else { // FOR BACKWARDS COMPATIBILITY IF GERBIL IS STILL =<1.1.0 [exitNode] = await db.select().from(exitNodes).limit(1); } if (!exitNode) { logger.warn( `Exit node not found for publicKey: ${exitNodePublicKey}` ); return next( createHttpError(HttpCode.NOT_FOUND, "Exit node not found") ); } const destinations = await updateAndGenerateEndpointDestinations( olmId, newtId, ip, port, timestamp, token, publicKey, exitNode ); // logger.debug( // `Returning ${destinations.length} peer destinations for olmId: ${olmId} or newtId: ${newtId}: ${JSON.stringify(destinations, null, 2)}` // ); // Return the new multi-peer structure return res.status(HttpCode.OK).send({ destinations: destinations }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } export async function updateAndGenerateEndpointDestinations( olmId: string | undefined, newtId: string | undefined, ip: string, port: number, timestamp: number, token: string, publicKey: string, exitNode: ExitNode, checkOrg = false ) { let currentSiteId: number | undefined; const destinations: PeerDestination[] = []; if (olmId) { // logger.debug( // `Got hole punch with ip: ${ip}, port: ${port} for olmId: ${olmId}` // ); const { session, olm: olmSession } = await validateOlmSessionToken(token); if (!session || !olmSession) { throw new Error("Unauthorized"); } if (olmId !== olmSession.olmId) { logger.warn(`Olm ID mismatch: ${olmId} !== ${olmSession.olmId}`); throw new Error("Unauthorized"); } const [olm] = await db.select().from(olms).where(eq(olms.olmId, olmId)); if (!olm || !olm.clientId) { logger.warn(`Olm not found: ${olmId}`); throw new Error("Olm not found"); } const [updatedClient] = await db .update(clients) .set({ lastHolePunch: timestamp }) .where(eq(clients.clientId, olm.clientId)) .returning(); if ( (await checkExitNodeOrg( exitNode.exitNodeId, updatedClient.orgId )) && checkOrg ) { // not allowed logger.warn( `Exit node ${exitNode.exitNodeId} is not allowed for org ${updatedClient.orgId}` ); throw new Error("Exit node not allowed"); } // Get sites that are on this specific exit node and connected to this client const sitesOnExitNode = await db .select({ siteId: sites.siteId, subnet: sites.subnet, listenPort: sites.listenPort, publicKey: sites.publicKey, endpoint: clientSitesAssociationsCache.endpoint }) .from(sites) .innerJoin( clientSitesAssociationsCache, eq(sites.siteId, clientSitesAssociationsCache.siteId) ) .where( and( eq(sites.exitNodeId, exitNode.exitNodeId), eq(clientSitesAssociationsCache.clientId, olm.clientId) ) ); // Update clientSites for each site on this exit node for (const site of sitesOnExitNode) { // logger.debug( // `Updating site ${site.siteId} on exit node ${exitNode.exitNodeId}` // ); // Format the endpoint properly for both IPv4 and IPv6 const formattedEndpoint = formatEndpoint(ip, port); // if the public key or endpoint has changed, update it otherwise continue if ( site.endpoint === formattedEndpoint && site.publicKey === publicKey ) { continue; } const [updatedClientSitesAssociationsCache] = await db .update(clientSitesAssociationsCache) .set({ endpoint: formattedEndpoint, publicKey: publicKey }) .where( and( eq(clientSitesAssociationsCache.clientId, olm.clientId), eq(clientSitesAssociationsCache.siteId, site.siteId) ) ) .returning(); if ( updatedClientSitesAssociationsCache.endpoint !== site.endpoint && // this is the endpoint from the join table not the site updatedClient.pubKey === publicKey // only trigger if the client's public key matches the current public key which means it has registered so we dont prematurely send the update ) { logger.info( `ClientSitesAssociationsCache for client ${olm.clientId} and site ${site.siteId} endpoint changed from ${site.endpoint} to ${updatedClientSitesAssociationsCache.endpoint}` ); // Handle any additional logic for endpoint change handleClientEndpointChange( olm.clientId, updatedClientSitesAssociationsCache.endpoint! ); } } // logger.debug( // `Updated ${sitesOnExitNode.length} sites on exit node ${exitNode.exitNodeId}` // ); if (!updatedClient) { logger.warn(`Client not found for olm: ${olmId}`); throw new Error("Client not found"); } // Create a list of the destinations from the sites for (const site of sitesOnExitNode) { if (site.subnet && site.listenPort) { destinations.push({ destinationIP: site.subnet.split("/")[0], destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }); } } } else if (newtId) { // logger.debug( // `Got hole punch with ip: ${ip}, port: ${port} for newtId: ${newtId}` // ); const { session, newt: newtSession } = await validateNewtSessionToken(token); if (!session || !newtSession) { throw new Error("Unauthorized"); } if (newtId !== newtSession.newtId) { logger.warn( `Newt ID mismatch: ${newtId} !== ${newtSession.newtId}` ); throw new Error("Unauthorized"); } const [newt] = await db .select() .from(newts) .where(eq(newts.newtId, newtId)); if (!newt || !newt.siteId) { logger.warn(`Newt not found: ${newtId}`); throw new Error("Newt not found"); } const [site] = await db .select() .from(sites) .where(eq(sites.siteId, newt.siteId)) .limit(1); if ( (await checkExitNodeOrg(exitNode.exitNodeId, site.orgId)) && checkOrg ) { // not allowed logger.warn( `Exit node ${exitNode.exitNodeId} is not allowed for org ${site.orgId}` ); throw new Error("Exit node not allowed"); } currentSiteId = newt.siteId; // Format the endpoint properly for both IPv4 and IPv6 const formattedSiteEndpoint = formatEndpoint(ip, port); // Update the current site with the new endpoint const [updatedSite] = await db .update(sites) .set({ endpoint: formattedSiteEndpoint, lastHolePunch: timestamp }) .where(eq(sites.siteId, newt.siteId)) .returning(); if ( updatedSite.endpoint != site.endpoint && updatedSite.publicKey == publicKey ) { // only trigger if the site's public key matches the current public key which means it has registered so we dont prematurely send the update logger.info( `Site ${newt.siteId} endpoint changed from ${site.endpoint} to ${updatedSite.endpoint}` ); // Handle any additional logic for endpoint change handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!); } // if (!updatedSite || !updatedSite.subnet) { // logger.warn(`Site not found: ${newt.siteId}`); // throw new Error("Site not found"); // } // Find all clients that connect to this site // const sitesClientPairs = await db // .select() // .from(clientSites) // .where(eq(clientSites.siteId, newt.siteId)); // THE NEWT IS NOT SENDING RAW WG TO THE GERBIL SO IDK IF WE REALLY NEED THIS - REMOVING // Get client details for each client // for (const pair of sitesClientPairs) { // const [client] = await db // .select() // .from(clients) // .where(eq(clients.clientId, pair.clientId)); // if (client && client.endpoint) { // const [host, portStr] = client.endpoint.split(':'); // if (host && portStr) { // destinations.push({ // destinationIP: host, // destinationPort: parseInt(portStr, 10) // }); // } // } // } // If this is a newt/site, also add other sites in the same org // if (updatedSite.orgId) { // const orgSites = await db // .select() // .from(sites) // .where(eq(sites.orgId, updatedSite.orgId)); // for (const site of orgSites) { // // Don't add the current site to the destinations // if (site.siteId !== currentSiteId && site.subnet && site.endpoint && site.listenPort) { // const [host, portStr] = site.endpoint.split(':'); // if (host && portStr) { // destinations.push({ // destinationIP: host, // destinationPort: site.listenPort // }); // } // } // } // } } return destinations; } async function handleSiteEndpointChange(siteId: number, newEndpoint: string) { // Alert all clients connected to this site that the endpoint has changed (only if NOT relayed) try { // Get site details const [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site || !site.publicKey) { logger.warn(`Site ${siteId} not found or has no public key`); return; } // Get all non-relayed clients connected to this site const connectedClients = await db .select({ clientId: clients.clientId, olmId: olms.olmId, isRelayed: clientSitesAssociationsCache.isRelayed }) .from(clientSitesAssociationsCache) .innerJoin( clients, eq(clientSitesAssociationsCache.clientId, clients.clientId) ) .innerJoin(olms, eq(olms.clientId, clients.clientId)) .where( and( eq(clientSitesAssociationsCache.siteId, siteId), eq(clientSitesAssociationsCache.isRelayed, false) ) ); // Update each non-relayed client with the new site endpoint for (const client of connectedClients) { try { await updateOlmPeer( client.clientId, { siteId: siteId, publicKey: site.publicKey, endpoint: newEndpoint }, client.olmId ); logger.debug( `Updated client ${client.clientId} with new site ${siteId} endpoint: ${newEndpoint}` ); } catch (error) { logger.error( `Failed to update client ${client.clientId} with new site endpoint: ${error}` ); } } } catch (error) { logger.error( `Error handling site endpoint change for site ${siteId}: ${error}` ); } } async function handleClientEndpointChange( clientId: number, newEndpoint: string ) { // Alert all sites connected to this client that the endpoint has changed (only if NOT relayed) try { // Get client details const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client || !client.pubKey) { logger.warn(`Client ${clientId} not found or has no public key`); return; } // Get all non-relayed sites connected to this client const connectedSites = await db .select({ siteId: sites.siteId, newtId: newts.newtId, isRelayed: clientSitesAssociationsCache.isRelayed, subnet: clients.subnet }) .from(clientSitesAssociationsCache) .innerJoin( sites, eq(clientSitesAssociationsCache.siteId, sites.siteId) ) .innerJoin(newts, eq(newts.siteId, sites.siteId)) .innerJoin( clients, eq(clientSitesAssociationsCache.clientId, clients.clientId) ) .where( and( eq(clientSitesAssociationsCache.clientId, clientId), eq(clientSitesAssociationsCache.isRelayed, false) ) ); // Update each non-relayed site with the new client endpoint for (const siteData of connectedSites) { try { if (!siteData.subnet) { logger.warn( `Client ${clientId} has no subnet, skipping update for site ${siteData.siteId}` ); continue; } await updateNewtPeer( siteData.siteId, client.pubKey, { endpoint: newEndpoint }, siteData.newtId ); logger.debug( `Updated site ${siteData.siteId} with new client ${clientId} endpoint: ${newEndpoint}` ); } catch (error) { logger.error( `Failed to update site ${siteData.siteId} with new client endpoint: ${error}` ); } } } catch (error) { logger.error( `Error handling client endpoint change for client ${clientId}: ${error}` ); } } ================================================ FILE: server/routers/hybrid.ts ================================================ import { Router } from "express"; // Root routes export const hybridRouter = Router(); ================================================ FILE: server/routers/idp/createIdpOrgPolicy.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } 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 { OpenAPITags, registry } from "@server/openApi"; import config from "@server/lib/config"; import { eq, and } from "drizzle-orm"; import { idp, idpOrg } from "@server/db"; const paramsSchema = z.strictObject({ idpId: z.coerce.number(), orgId: z.string() }); const bodySchema = z.strictObject({ roleMapping: z.string().optional(), orgMapping: z.string().optional() }); export type CreateIdpOrgPolicyResponse = {}; registry.registerPath({ method: "put", path: "/idp/{idpId}/org/{orgId}", description: "Create an IDP policy for an existing IDP on an organization.", tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema, body: { content: { "application/json": { schema: bodySchema } } } }, responses: {} }); export async function createIdpOrgPolicy( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } 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) .leftJoin( idpOrg, and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId)) ) .where(eq(idp.idpId, idpId)); if (!existing?.idp) { return next( createHttpError( HttpCode.BAD_REQUEST, "An IDP with this ID does not exist." ) ); } if (existing.idpOrg) { return next( createHttpError( HttpCode.BAD_REQUEST, "An IDP org policy already exists." ) ); } await db.insert(idpOrg).values({ idpId, orgId, roleMapping, orgMapping }); return response(res, { data: {}, success: true, error: false, message: "Idp created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/idp/createOidcIdp.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } 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 { OpenAPITags, registry } from "@server/openApi"; import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; const paramsSchema = z.strictObject({}); const bodySchema = z.strictObject({ name: z.string().nonempty(), clientId: z.string().nonempty(), clientSecret: z.string().nonempty(), authUrl: z.url(), tokenUrl: z.url(), identifierPath: z.string().nonempty(), emailPath: z.string().optional(), namePath: z.string().optional(), scopes: z.string().nonempty(), autoProvision: z.boolean().optional(), tags: z.string().optional() }); export type CreateIdpResponse = { idpId: number; redirectUrl: string; }; registry.registerPath({ method: "put", path: "/idp/oidc", description: "Create an OIDC IdP.", tags: [OpenAPITags.GlobalIdp], request: { body: { content: { "application/json": { schema: bodySchema } } } }, responses: {} }); export async function createOidcIdp( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { clientId, clientSecret, authUrl, tokenUrl, scopes, identifierPath, emailPath, namePath, name, autoProvision, 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); const encryptedClientId = encrypt(clientId, key); let idpId: number | undefined; await db.transaction(async (trx) => { const [idpRes] = await trx .insert(idp) .values({ name, autoProvision, type: "oidc", tags, defaultOrgMapping: `'{{orgId}}'`, defaultRoleMapping: `'Member'` }) .returning(); idpId = idpRes.idpId; await trx.insert(idpOidcConfig).values({ idpId: idpRes.idpId, clientId: encryptedClientId, clientSecret: encryptedSecret, authUrl, tokenUrl, scopes, identifierPath, emailPath, namePath }); }); const redirectUrl = await generateOidcRedirectUrl(idpId as number); return response(res, { data: { idpId: idpId as number, redirectUrl }, success: true, error: false, message: "Idp created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/idp/deleteIdp.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } 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 { idp, idpOidcConfig, idpOrg } from "@server/db"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z .object({ orgId: z.string().optional(), // Optional; used with org idp in saas idpId: z.coerce.number() }) .strict(); registry.registerPath({ method: "delete", path: "/idp/{idpId}", description: "Delete IDP.", tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema }, responses: {} }); export async function deleteIdp( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { idpId } = parsedParams.data; // Check if IDP exists const [existingIdp] = await db .select() .from(idp) .where(eq(idp.idpId, idpId)); if (!existingIdp) { return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found")); } // Delete the IDP and its related records in a transaction await db.transaction(async (trx) => { // Delete OIDC config if it exists await trx .delete(idpOidcConfig) .where(eq(idpOidcConfig.idpId, idpId)); // Delete IDP-org mappings await trx.delete(idpOrg).where(eq(idpOrg.idpId, idpId)); // Delete the IDP itself await trx.delete(idp).where(eq(idp.idpId, idpId)); }); return response(res, { data: null, success: true, error: false, message: "IdP deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/idp/deleteIdpOrgPolicy.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } 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 { idp, idpOrg } from "@server/db"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.strictObject({ idpId: z.coerce.number(), orgId: z.string() }); registry.registerPath({ method: "delete", path: "/idp/{idpId}/org/{orgId}", description: "Create an OIDC IdP for an organization.", tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema }, responses: {} }); export async function deleteIdpOrgPolicy( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { idpId, orgId } = parsedParams.data; const [existing] = await db .select() .from(idp) .leftJoin(idpOrg, eq(idpOrg.orgId, orgId)) .where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId))); if (!existing.idp) { return next( createHttpError( HttpCode.BAD_REQUEST, "An IDP with this ID does not exist." ) ); } if (!existing.idpOrg) { return next( createHttpError( HttpCode.BAD_REQUEST, "A policy for this IDP and org does not exist." ) ); } await db .delete(idpOrg) .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); return response(res, { data: null, success: true, error: false, message: "Policy deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/idp/generateOidcUrl.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } 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 { idp, idpOidcConfig, idpOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import * as arctic from "arctic"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import jsonwebtoken from "jsonwebtoken"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { build } from "@server/build"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; const paramsSchema = z .object({ idpId: z.coerce.number() }) .strict(); const bodySchema = z.strictObject({ redirectUrl: z.string() }); const querySchema = z.object({ orgId: z.string().optional() // check what actuall calls it }); const ensureTrailingSlash = (url: string): string => { return url; }; export type GenerateOidcUrlResponse = { redirectUrl: string; }; export async function generateOidcUrl( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { idpId } = parsedParams.data; const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { redirectUrl: postAuthRedirectUrl } = parsedBody.data; const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { orgId } = parsedQuery.data; const [existingIdp] = await db .select() .from(idp) .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId))); if (!existingIdp) { return next( createHttpError( HttpCode.BAD_REQUEST, "IdP not found for the organization" ) ); } if (orgId) { const [idpOrgLink] = await db .select() .from(idpOrg) .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))) .limit(1); if (!idpOrgLink) { return next( createHttpError( HttpCode.BAD_REQUEST, "IdP not found for the organization" ) ); } if (build === "saas") { const subscribed = await isSubscribed( orgId, tierMatrix.orgOidc ); if (!subscribed) { return next( createHttpError( HttpCode.FORBIDDEN, "This organization's current plan does not support this feature." ) ); } } } const parsedScopes = existingIdp.idpOidcConfig.scopes .split(" ") .map((scope) => { return scope.trim(); }) .filter((scope) => { return scope.length > 0; }); const key = config.getRawConfig().server.secret!; const decryptedClientId = decrypt( existingIdp.idpOidcConfig.clientId, key ); const decryptedClientSecret = decrypt( existingIdp.idpOidcConfig.clientSecret, key ); const redirectUrl = await generateOidcRedirectUrl(idpId, orgId); logger.debug("OIDC client info", { decryptedClientId, decryptedClientSecret, redirectUrl }); const client = new arctic.OAuth2Client( decryptedClientId, decryptedClientSecret, redirectUrl ); const codeVerifier = arctic.generateCodeVerifier(); const state = arctic.generateState(); const url = client.createAuthorizationURLWithPKCE( ensureTrailingSlash(existingIdp.idpOidcConfig.authUrl), state, arctic.CodeChallengeMethod.S256, codeVerifier, parsedScopes ); const stateJwt = jsonwebtoken.sign( { redirectUrl: postAuthRedirectUrl, // TODO: validate that this is safe state, codeVerifier }, config.getRawConfig().server.secret! ); res.cookie("p_oidc_state", stateJwt, { path: "/", httpOnly: true, secure: req.protocol === "https", expires: new Date(Date.now() + 60 * 10 * 1000), sameSite: "lax" }); return response(res, { data: { redirectUrl: url.toString() }, success: true, error: false, message: "Idp auth url generated", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/idp/getIdp.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { idp, idpOidcConfig } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; const paramsSchema = z .object({ idpId: z.coerce.number() }) .strict(); async function query(idpId: number) { const [res] = await db .select() .from(idp) .where(eq(idp.idpId, idpId)) .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .limit(1); return res; } export type GetIdpResponse = NonNullable>>; registry.registerPath({ method: "get", path: "/idp/{idpId}", description: "Get an IDP by its IDP ID.", tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema }, responses: {} }); export async function getIdp( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { idpId } = parsedParams.data; const idpRes = await query(idpId); if (!idpRes) { return next(createHttpError(HttpCode.NOT_FOUND, "Idp not found")); } const key = config.getRawConfig().server.secret!; if (idpRes.idp.type === "oidc") { const clientSecret = idpRes.idpOidcConfig!.clientSecret; const clientId = idpRes.idpOidcConfig!.clientId; idpRes.idpOidcConfig!.clientSecret = decrypt(clientSecret, key); idpRes.idpOidcConfig!.clientId = decrypt(clientId, key); } return response(res, { data: idpRes, success: true, error: false, message: "Idp retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/idp/index.ts ================================================ export * from "./createOidcIdp"; export * from "./updateOidcIdp"; export * from "./deleteIdp"; export * from "./listIdps"; export * from "./generateOidcUrl"; export * from "./validateOidcCallback"; export * from "./getIdp"; export * from "./createIdpOrgPolicy"; export * from "./deleteIdpOrgPolicy"; export * from "./listIdpOrgPolicies"; export * from "./updateIdpOrgPolicy"; ================================================ FILE: server/routers/idp/listIdpOrgPolicies.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { idpOrg } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.object({ idpId: z.coerce.number() }); const querySchema = z.strictObject({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().nonnegative()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); async function query(idpId: number, limit: number, offset: number) { const res = await db .select() .from(idpOrg) .where(eq(idpOrg.idpId, idpId)) .limit(limit) .offset(offset); return res; } export type ListIdpOrgPoliciesResponse = { policies: NonNullable>>; pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ method: "get", path: "/idp/{idpId}/org", description: "List all org policies on an IDP.", tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema, query: querySchema }, responses: {} }); export async function listIdpOrgPolicies( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { idpId } = parsedParams.data; const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { limit, offset } = parsedQuery.data; const list = await query(idpId, limit, offset); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(idpOrg) .where(eq(idpOrg.idpId, idpId)); return response(res, { data: { policies: list, pagination: { total: count, limit, offset } }, success: true, error: false, message: "Policies retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/idp/listIdps.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idpOidcConfig } from "@server/db"; import { domains, idp, orgDomains, users, idpOrg } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const querySchema = z.strictObject({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().nonnegative()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); async function query(limit: number, offset: number) { const res = await db .select({ idpId: idp.idpId, name: idp.name, type: idp.type, variant: idpOidcConfig.variant, orgCount: sql`count(${idpOrg.orgId})`, autoProvision: idp.autoProvision, tags: idp.tags }) .from(idp) .leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`) .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .groupBy(idp.idpId, idpOidcConfig.variant) .limit(limit) .offset(offset); return res; } export type ListIdpsResponse = { idps: Awaited>; pagination: { total: number; limit: number; offset: number; }; }; registry.registerPath({ method: "get", path: "/idp", description: "List all IDP in the system.", tags: [OpenAPITags.GlobalIdp], request: { query: querySchema }, responses: {} }); export async function listIdps( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { limit, offset } = parsedQuery.data; const list = await query(limit, offset); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(idp); return response(res, { data: { idps: list, pagination: { total: count, limit, offset } }, success: true, error: false, message: "Idps retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/idp/updateIdpOrgPolicy.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } 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 { OpenAPITags, registry } from "@server/openApi"; import { eq, and } from "drizzle-orm"; import { idp, idpOrg } from "@server/db"; const paramsSchema = z.strictObject({ idpId: z.coerce.number(), orgId: z.string() }); const bodySchema = z.strictObject({ roleMapping: z.string().optional(), orgMapping: z.string().optional() }); export type UpdateIdpOrgPolicyResponse = {}; registry.registerPath({ method: "post", path: "/idp/{idpId}/org/{orgId}", description: "Update an IDP org policy.", tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema, body: { content: { "application/json": { schema: bodySchema } } } }, responses: {} }); export async function updateIdpOrgPolicy( req: Request, res: Response, next: NextFunction ): Promise { 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 { 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() .from(idp) .leftJoin( idpOrg, and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId)) ) .where(eq(idp.idpId, idpId)); if (!existing?.idp) { return next( createHttpError( HttpCode.BAD_REQUEST, "An IDP with this ID does not exist." ) ); } if (!existing.idpOrg) { return next( createHttpError( HttpCode.BAD_REQUEST, "A policy for this IDP and org does not exist." ) ); } // Update the policy await db .update(idpOrg) .set({ roleMapping, orgMapping }) .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); return response(res, { data: {}, success: true, error: false, message: "Policy updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/idp/updateOidcIdp.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } 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 { OpenAPITags, registry } from "@server/openApi"; import { idp, idpOidcConfig } from "@server/db"; import { eq } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; const paramsSchema = z .object({ idpId: z.coerce.number() }) .strict(); const bodySchema = z.strictObject({ name: z.string().optional(), clientId: z.string().optional(), clientSecret: z.string().optional(), authUrl: z.string().optional(), tokenUrl: z.string().optional(), identifierPath: z.string().optional(), emailPath: z.string().optional(), namePath: z.string().optional(), scopes: z.string().optional(), autoProvision: z.boolean().optional(), defaultRoleMapping: z.string().optional(), defaultOrgMapping: z.string().optional(), tags: z.string().optional() }); export type UpdateIdpResponse = { idpId: number; }; registry.registerPath({ method: "post", path: "/idp/{idpId}/oidc", description: "Update an OIDC IdP.", tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema, body: { content: { "application/json": { schema: bodySchema } } } }, responses: {} }); export async function updateOidcIdp( req: Request, res: Response, next: NextFunction ): Promise { 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 { idpId } = parsedParams.data; const { clientId, clientSecret, authUrl, tokenUrl, scopes, identifierPath, emailPath, namePath, name, autoProvision, defaultRoleMapping, defaultOrgMapping, 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() .from(idp) .where(eq(idp.idpId, idpId)); if (!existingIdp) { return next(createHttpError(HttpCode.NOT_FOUND, "IdP not found")); } if (existingIdp.type !== "oidc") { return next( createHttpError( HttpCode.BAD_REQUEST, "IdP is not an OIDC provider" ) ); } const key = config.getRawConfig().server.secret!; const encryptedSecret = clientSecret ? encrypt(clientSecret, key) : undefined; const encryptedClientId = clientId ? encrypt(clientId, key) : undefined; await db.transaction(async (trx) => { const idpData = { name, autoProvision, defaultRoleMapping, defaultOrgMapping, tags }; // only update if at least one key is not undefined let keysToUpdate = Object.keys(idpData).filter( (key) => idpData[key as keyof typeof idpData] !== undefined ); if (keysToUpdate.length > 0) { await trx.update(idp).set(idpData).where(eq(idp.idpId, idpId)); } const configData = { clientId: encryptedClientId, clientSecret: encryptedSecret, authUrl, tokenUrl, scopes, identifierPath, emailPath, namePath }; keysToUpdate = Object.keys(configData).filter( (key) => configData[key as keyof typeof configData] !== undefined ); if (keysToUpdate.length > 0) { // Update OIDC config await trx .update(idpOidcConfig) .set(configData) .where(eq(idpOidcConfig.idpId, idpId)); } }); return response(res, { data: { idpId }, success: true, error: false, message: "IdP updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/idp/validateOidcCallback.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, Org } 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 { idp, idpOidcConfig, idpOrg, orgs, Role, roles, userOrgs, users } from "@server/db"; import { and, eq, inArray } from "drizzle-orm"; import * as arctic from "arctic"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import jmespath from "jmespath"; import jsonwebtoken from "jsonwebtoken"; import config from "@server/lib/config"; import { createSession, generateId, generateSessionToken, serializeSessionCookie } from "@server/auth/sessions/app"; import { decrypt } from "@server/lib/crypto"; import { UserType } from "@server/types/UserTypes"; import { FeatureId } from "@server/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; 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; }; const paramsSchema = z .object({ idpId: z.coerce.number() }) .strict(); const bodySchema = z.object({ code: z.string().nonempty(), state: z.string().nonempty(), storedState: z.string().nonempty() }); const querySchema = z.object({ loginPageId: z.coerce.number().optional() }); export type ValidateOidcUrlCallbackResponse = { redirectUrl: string; }; export async function validateOidcCallback( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { idpId } = parsedParams.data; const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { loginPageId } = parsedQuery.data; const { storedState, code, state: expectedState } = parsedBody.data; const [existingIdp] = await db .select() .from(idp) .innerJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .where(and(eq(idp.type, "oidc"), eq(idp.idpId, idpId))); if (!existingIdp) { return next( createHttpError( HttpCode.BAD_REQUEST, "IdP not found for the organization" ) ); } const key = config.getRawConfig().server.secret!; const decryptedClientId = decrypt( existingIdp.idpOidcConfig.clientId, key ); const decryptedClientSecret = decrypt( existingIdp.idpOidcConfig.clientSecret, key ); const redirectUrl = await generateOidcRedirectUrl( existingIdp.idp.idpId, undefined, loginPageId ); const client = new arctic.OAuth2Client( decryptedClientId, decryptedClientSecret, redirectUrl ); const statePayload = jsonwebtoken.verify( storedState, config.getRawConfig().server.secret!, function (err, decoded) { if (err) { logger.error("Error verifying state JWT", { err }); return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid state JWT" ) ); } return decoded; } ); const stateObj = z .object({ redirectUrl: z.string(), state: z.string(), codeVerifier: z.string() }) .safeParse(statePayload); if (!stateObj.success) { logger.error("Error parsing state JWT"); return next( createHttpError( HttpCode.BAD_REQUEST, fromError(stateObj.error).toString() ) ); } const { codeVerifier, state, redirectUrl: postAuthRedirectUrl } = stateObj.data; if (state !== expectedState) { logger.error("State mismatch", { expectedState, state }); return next( createHttpError(HttpCode.BAD_REQUEST, "State mismatch") ); } logger.debug("State verified", { urL: ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl), expectedState, state }); let tokens: arctic.OAuth2Tokens; try { tokens = await client.validateAuthorizationCode( ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl), code, codeVerifier ); } catch (err: unknown) { if (err instanceof arctic.OAuth2RequestError) { logger.warn("OIDC provider rejected the authorization code", { error: err.code, description: err.description, uri: err.uri, state: err.state }); return next( createHttpError( HttpCode.UNAUTHORIZED, err.description || `OIDC provider rejected the request (${err.code})` ) ); } if (err instanceof arctic.UnexpectedResponseError) { logger.error( "OIDC provider returned an unexpected response during token exchange", { status: err.status } ); return next( createHttpError( HttpCode.BAD_GATEWAY, "Received an unexpected response from the identity provider while exchanging the authorization code." ) ); } if (err instanceof arctic.UnexpectedErrorResponseBodyError) { logger.error( "OIDC provider returned an unexpected error payload during token exchange", { status: err.status, data: err.data } ); return next( createHttpError( HttpCode.BAD_GATEWAY, "Identity provider returned an unexpected error payload while exchanging the authorization code." ) ); } if (err instanceof arctic.ArcticFetchError) { logger.error( "Failed to reach OIDC provider while exchanging authorization code", { error: err.message } ); return next( createHttpError( HttpCode.BAD_GATEWAY, "Unable to reach the identity provider while exchanging the authorization code. Please try again." ) ); } throw err; } const idToken = tokens.idToken(); logger.debug("ID token", { idToken }); const claims = arctic.decodeIdToken(idToken); logger.debug("ID token claims", { claims }); let userIdentifier = jmespath.search( claims, existingIdp.idpOidcConfig.identifierPath ) as string | null; if (!userIdentifier) { return next( createHttpError( HttpCode.BAD_REQUEST, "User identifier not found in the ID token" ) ); } userIdentifier = userIdentifier.toLowerCase(); logger.debug("User identifier", { userIdentifier }); let email = null; let name = null; try { if (existingIdp.idpOidcConfig.emailPath) { email = jmespath.search( claims, existingIdp.idpOidcConfig.emailPath ); } if (existingIdp.idpOidcConfig.namePath) { name = jmespath.search( claims, existingIdp.idpOidcConfig.namePath || "" ); } } catch (error) {} logger.debug("User email", { email }); logger.debug("User name", { name }); if (email) { email = email.toLowerCase(); } const [existingUser] = await db .select() .from(users) .where( and( eq(users.username, userIdentifier), eq(users.idpId, existingIdp.idp.idpId) ) ); if (existingIdp.idp.autoProvision) { let allOrgs: Org[] = []; if (build === "saas") { const idpOrgs = await db .select() .from(idpOrg) .where(eq(idpOrg.idpId, existingIdp.idp.idpId)) .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)); allOrgs = idpOrgs.map((o) => o.orgs); // TODO: when there are multiple orgs we need to do this better!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!1 if (allOrgs.length > 1) { // for some reason there is more than one org logger.error( "More than one organization linked to this IdP. This should not happen with auto-provisioning enabled." ); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Multiple organizations linked to this IdP. Please contact support." ) ); } const subscribed = await isSubscribed( allOrgs[0].orgId, tierMatrix.autoProvisioning ); if (!subscribed) { return next( createHttpError( HttpCode.FORBIDDEN, "This organization's current plan does not support this feature." ) ); } } else { allOrgs = await db.select().from(orgs); } const defaultRoleMapping = existingIdp.idp.defaultRoleMapping; const defaultOrgMapping = existingIdp.idp.defaultOrgMapping; const userOrgInfo: { orgId: string; roleId: number }[] = []; for (const org of allOrgs) { const [idpOrgRes] = await db .select() .from(idpOrg) .where( and( eq(idpOrg.idpId, existingIdp.idp.idpId), eq(idpOrg.orgId, org.orgId) ) ); let roleId: number | undefined = undefined; const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping; const hydratedOrgMapping = hydrateOrgMapping( orgMapping, org.orgId ); if (hydratedOrgMapping) { logger.debug("Hydrated Org Mapping", { hydratedOrgMapping }); const orgId = jmespath.search(claims, hydratedOrgMapping); logger.debug("Extraced Org ID", { orgId }); if (orgId !== true && orgId !== org.orgId) { // user not allowed to access this org continue; } } // user could be allowed in this org, now find the role const roleMapping = idpOrgRes?.roleMapping || defaultRoleMapping; if (roleMapping) { logger.debug("Role Mapping", { roleMapping }); const roleName = jmespath.search(claims, roleMapping); if (!roleName) { logger.error("Role name not found in the ID token", { roleName }); continue; } const [roleRes] = await db .select() .from(roles) .where( and( eq(roles.orgId, org.orgId), eq(roles.name, roleName) ) ); if (!roleRes) { logger.error("Role not found", { orgId: org.orgId, roleName }); continue; } roleId = roleRes.roleId; userOrgInfo.push({ orgId: org.orgId, roleId }); } } // 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; if (!userOrgInfo.length) { if (existingUser) { // get existing user orgs const existingUserOrgs = await db .select() .from(userOrgs) .where( and( eq(userOrgs.userId, existingUser.userId), eq(userOrgs.autoProvisioned, false) ) ); if (!existingUserOrgs.length) { // 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); return next( createHttpError( HttpCode.UNAUTHORIZED, `No policies matched for ${userIdentifier}. This user must be added to an organization before logging in.` ) ); } } else { // no orgs to provision and user doesn't exist return next( createHttpError( HttpCode.UNAUTHORIZED, `No policies matched for ${userIdentifier}. This user must be added to an organization before logging in.` ) ); } } const orgUserCounts: { orgId: string; userCount: number }[] = []; // sync the user with the orgs and roles await db.transaction(async (trx) => { let userId = existingUser?.userId; // create user if not exists if (!existingUser) { userId = generateId(15); await trx.insert(users).values({ userId, username: userIdentifier, email: email || null, name: name || null, type: UserType.OIDC, idpId: existingIdp.idp.idpId, emailVerified: true, // OIDC users are always verified dateCreated: new Date().toISOString() }); } else { // set the name and email await trx .update(users) .set({ username: userIdentifier, email: email || null, name: name || null }) .where(eq(users.userId, userId!)); } existingUserId = userId; // get all current user orgs const currentUserOrgs = await trx .select() .from(userOrgs) .where(eq(userOrgs.userId, userId!)); // Filter to only auto-provisioned orgs for CRUD operations const autoProvisionedOrgs = currentUserOrgs.filter( (org) => org.autoProvisioned === true ); // Delete auto-provisioned orgs that are no longer valid const orgsToDelete = autoProvisionedOrgs.filter( (currentOrg) => !userOrgInfo.some( (newOrg) => newOrg.orgId === currentOrg.orgId ) ); if (orgsToDelete.length > 0) { 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 const orgsToUpdate = autoProvisionedOrgs.filter( (currentOrg) => { const newOrg = userOrgInfo.find( (newOrg) => newOrg.orgId === currentOrg.orgId ); return newOrg && newOrg.roleId !== currentOrg.roleId; } ); if (orgsToUpdate.length > 0) { for (const org of orgsToUpdate) { const newRole = userOrgInfo.find( (newOrg) => newOrg.orgId === org.orgId ); if (newRole) { await trx .update(userOrgs) .set({ roleId: newRole.roleId }) .where( and( eq(userOrgs.userId, userId!), eq(userOrgs.orgId, org.orgId) ) ); } } } // Add new orgs that don't exist yet (these will be auto-provisioned) const orgsToAdd = userOrgInfo.filter( (newOrg) => !currentUserOrgs.some( (currentOrg) => currentOrg.orgId === newOrg.orgId ) ); if (orgsToAdd.length > 0) { 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 // Use all current user orgs (both auto-provisioned and manually added) for counting for (const org of currentUserOrgs) { const userCount = await trx .select() .from(userOrgs) .where(eq(userOrgs.orgId, org.orgId)); orgUserCounts.push({ orgId: org.orgId, userCount: userCount.length }); } await calculateUserClientsForOrgs(userId!, trx); }); for (const orgCount of orgUserCounts) { await usageService.updateCount( orgCount.orgId, FeatureId.USERS, orgCount.userCount ); } const token = generateSessionToken(); const sess = await createSession(token, existingUserId!); const isSecure = req.protocol === "https"; const cookie = serializeSessionCookie( token, isSecure, new Date(sess.expiresAt) ); res.appendHeader("Set-Cookie", cookie); let finalRedirectUrl = postAuthRedirectUrl; if (loginPageId) { finalRedirectUrl = `/auth/org/?redirect=${encodeURIComponent( postAuthRedirectUrl )}`; } logger.debug("Final redirect URL", { finalRedirectUrl }); return response(res, { data: { redirectUrl: finalRedirectUrl }, success: true, error: false, message: "OIDC callback validated successfully", status: HttpCode.CREATED }); } else { if (!existingUser) { return next( createHttpError( HttpCode.UNAUTHORIZED, `User with username ${userIdentifier} is unprovisioned. This user must be added to an organization before logging in.` ) ); } // check for existing user orgs const existingUserOrgs = await db .select() .from(userOrgs) .where(and(eq(userOrgs.userId, existingUser.userId))); if (!existingUserOrgs.length) { logger.debug( "No existing user orgs found for non-auto-provisioned IdP" ); return next( createHttpError( HttpCode.UNAUTHORIZED, `User with username ${userIdentifier} is unprovisioned. This user must be added to an organization before logging in.` ) ); } const token = generateSessionToken(); const sess = await createSession(token, existingUser.userId); const isSecure = req.protocol === "https"; const cookie = serializeSessionCookie( token, isSecure, new Date(sess.expiresAt) ); res.appendHeader("Set-Cookie", cookie); return response(res, { data: { redirectUrl: postAuthRedirectUrl }, success: true, error: false, message: "OIDC callback validated successfully", status: HttpCode.CREATED }); } } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } function hydrateOrgMapping( orgMapping: string | null, orgId: string ): string | undefined { if (!orgMapping) { return undefined; } return orgMapping.split("{{orgId}}").join(orgId); } ================================================ FILE: server/routers/integration.ts ================================================ import * as site from "./site"; import * as org from "./org"; import * as blueprints from "./blueprints"; import * as resource from "./resource"; import * as domain from "./domain"; import * as target from "./target"; import * as user from "./user"; import * as role from "./role"; import * as client from "./client"; import * as accessToken from "./accessToken"; import * as apiKeys from "./apiKeys"; import * as idp from "./idp"; import * as logs from "./auditLogs"; import * as siteResource from "./siteResource"; import { verifyApiKey, verifyApiKeyOrgAccess, verifyApiKeyHasAction, verifyApiKeySiteAccess, verifyApiKeyResourceAccess, verifyApiKeyTargetAccess, verifyApiKeyRoleAccess, verifyApiKeyUserAccess, verifyApiKeySetResourceUsers, verifyApiKeyAccessTokenAccess, verifyApiKeyIsRoot, verifyApiKeyClientAccess, verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, verifyLimits, verifyApiKeyDomainAccess } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; import { ActionsEnum } from "@server/auth/actions"; import { logActionAudit } from "#dynamic/middlewares"; export const unauthenticated = Router(); unauthenticated.get("/", (_, res) => { res.status(HttpCode.OK).json({ message: "Healthy" }); }); export const authenticated = Router(); authenticated.use(verifyApiKey); authenticated.get( "/org/checkId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.checkOrgId), org.checkId ); authenticated.put( "/org", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createOrg), logActionAudit(ActionsEnum.createOrg), org.createOrg ); authenticated.get( "/orgs", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.listOrgs), org.listOrgs ); // TODO we need to check the orgs here authenticated.get( "/org/:orgId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.getOrg), org.getOrg ); authenticated.post( "/org/:orgId", verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateOrg), logActionAudit(ActionsEnum.updateOrg), org.updateOrg ); authenticated.delete( "/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteOrg), logActionAudit(ActionsEnum.deleteOrg), org.deleteOrg ); authenticated.put( "/org/:orgId/site", verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createSite), logActionAudit(ActionsEnum.createSite), site.createSite ); authenticated.get( "/org/:orgId/sites", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listSites), site.listSites ); authenticated.get( "/org/:orgId/site/:niceId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.getSite), site.getSite ); authenticated.get( "/org/:orgId/pick-site-defaults", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createSite), site.pickSiteDefaults ); authenticated.get( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.getSite), site.getSite ); authenticated.post( "/site/:siteId", verifyApiKeySiteAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateSite), logActionAudit(ActionsEnum.updateSite), site.updateSite ); authenticated.post( "/org/:orgId/reset-bandwidth", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.resetSiteBandwidth), logActionAudit(ActionsEnum.resetSiteBandwidth), org.resetOrgBandwidth ); authenticated.delete( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.deleteSite), logActionAudit(ActionsEnum.deleteSite), site.deleteSite ); authenticated.get( "/org/:orgId/user-resources", verifyApiKeyOrgAccess, resource.getUserResources ); // Site Resource endpoints authenticated.put( "/org/:orgId/site-resource", verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createSiteResource), logActionAudit(ActionsEnum.createSiteResource), siteResource.createSiteResource ); authenticated.get( "/org/:orgId/site/:siteId/resources", verifyApiKeyOrgAccess, verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.listSiteResources), siteResource.listSiteResources ); authenticated.get( "/org/:orgId/site-resources", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listSiteResources), siteResource.listAllSiteResourcesByOrg ); authenticated.get( "/site-resource/:siteResourceId", verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.getSiteResource), siteResource.getSiteResource ); authenticated.post( "/site-resource/:siteResourceId", verifyApiKeySiteResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), logActionAudit(ActionsEnum.updateSiteResource), siteResource.updateSiteResource ); authenticated.delete( "/site-resource/:siteResourceId", verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource), siteResource.deleteSiteResource ); authenticated.get( "/site-resource/:siteResourceId/roles", verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceRoles), siteResource.listSiteResourceRoles ); authenticated.get( "/site-resource/:siteResourceId/users", verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceUsers), siteResource.listSiteResourceUsers ); authenticated.get( "/site-resource/:siteResourceId/clients", verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceUsers), siteResource.listSiteResourceClients ); authenticated.post( "/site-resource/:siteResourceId/roles", verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), siteResource.setSiteResourceRoles ); authenticated.post( "/site-resource/:siteResourceId/users", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.setSiteResourceUsers ); authenticated.post( "/site-resource/:siteResourceId/roles/add", verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), siteResource.addRoleToSiteResource ); authenticated.post( "/site-resource/:siteResourceId/roles/remove", verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), siteResource.removeRoleFromSiteResource ); authenticated.post( "/site-resource/:siteResourceId/users/add", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.addUserToSiteResource ); authenticated.post( "/site-resource/:siteResourceId/users/remove", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.removeUserFromSiteResource ); authenticated.post( "/site-resource/:siteResourceId/clients", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.setSiteResourceClients ); authenticated.post( "/site-resource/:siteResourceId/clients/add", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.addClientToSiteResource ); authenticated.post( "/site-resource/:siteResourceId/clients/remove", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.removeClientFromSiteResource ); authenticated.post( "/client/:clientId/site-resources", verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.batchAddClientToSiteResources ); authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResource), logActionAudit(ActionsEnum.createResource), resource.createResource ); authenticated.put( "/org/:orgId/site/:siteId/resource", verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResource), logActionAudit(ActionsEnum.createResource), resource.createResource ); authenticated.get( "/site/:siteId/resources", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.listResources), resource.listResources ); authenticated.get( "/org/:orgId/resources", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listResources), resource.listResources ); authenticated.get( "/org/:orgId/domains", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listOrgDomains), domain.listDomains ); authenticated.get( "/org/:orgId/domain/:domainId", verifyApiKeyOrgAccess, verifyApiKeyDomainAccess, verifyApiKeyHasAction(ActionsEnum.getDomain), domain.getDomain ); authenticated.put( "/org/:orgId/domain", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createOrgDomain), logActionAudit(ActionsEnum.createOrgDomain), domain.createOrgDomain ); authenticated.patch( "/org/:orgId/domain/:domainId", verifyApiKeyOrgAccess, verifyApiKeyDomainAccess, verifyApiKeyHasAction(ActionsEnum.updateOrgDomain), domain.updateOrgDomain ); authenticated.delete( "/org/:orgId/domain/:domainId", verifyApiKeyOrgAccess, verifyApiKeyDomainAccess, verifyApiKeyHasAction(ActionsEnum.deleteOrgDomain), logActionAudit(ActionsEnum.deleteOrgDomain), domain.deleteAccountDomain ); authenticated.get( "/org/:orgId/domain/:domainId/dns-records", verifyApiKeyOrgAccess, verifyApiKeyDomainAccess, verifyApiKeyHasAction(ActionsEnum.getDNSRecords), domain.getDNSRecords ); authenticated.post( "/org/:orgId/domain/:domainId/restart", verifyApiKeyOrgAccess, verifyApiKeyDomainAccess, verifyApiKeyHasAction(ActionsEnum.restartOrgDomain), logActionAudit(ActionsEnum.restartOrgDomain), domain.restartOrgDomain ); authenticated.get( "/org/:orgId/invitations", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listInvitations), user.listInvitations ); authenticated.post( "/org/:orgId/create-invite", verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.inviteUser), logActionAudit(ActionsEnum.inviteUser), user.inviteUser ); authenticated.delete( "/org/:orgId/invitations/:inviteId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.removeInvitation), logActionAudit(ActionsEnum.removeInvitation), user.removeInvitation ); authenticated.get( "/resource/:resourceId/roles", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceRoles), resource.listResourceRoles ); authenticated.get( "/resource/:resourceId/users", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceUsers), resource.listResourceUsers ); authenticated.get( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.getResource), resource.getResource ); authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateResource), logActionAudit(ActionsEnum.updateResource), resource.updateResource ); authenticated.delete( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResource), logActionAudit(ActionsEnum.deleteResource), resource.deleteResource ); authenticated.put( "/resource/:resourceId/target", verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), target.createTarget ); authenticated.get( "/resource/:resourceId/targets", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listTargets), target.listTargets ); authenticated.put( "/resource/:resourceId/rule", verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResourceRule), logActionAudit(ActionsEnum.createResourceRule), resource.createResourceRule ); authenticated.get( "/resource/:resourceId/rules", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listResourceRules), resource.listResourceRules ); authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), logActionAudit(ActionsEnum.updateResourceRule), resource.updateResourceRule ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), logActionAudit(ActionsEnum.deleteResourceRule), resource.deleteResourceRule ); authenticated.get( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.getTarget), target.getTarget ); authenticated.post( "/target/:targetId", verifyApiKeyTargetAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateTarget), logActionAudit(ActionsEnum.updateTarget), target.updateTarget ); authenticated.delete( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.deleteTarget), logActionAudit(ActionsEnum.deleteTarget), target.deleteTarget ); authenticated.put( "/org/:orgId/role", verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createRole), logActionAudit(ActionsEnum.createRole), role.createRole ); authenticated.post( "/role/:roleId", verifyApiKeyRoleAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateRole), logActionAudit(ActionsEnum.updateRole), role.updateRole ); authenticated.get( "/org/:orgId/roles", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listRoles), role.listRoles ); authenticated.delete( "/role/:roleId", verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.deleteRole), logActionAudit(ActionsEnum.deleteRole), role.deleteRole ); authenticated.get( "/role/:roleId", verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.getRole), role.getRole ); authenticated.post( "/role/:roleId/add/:userId", verifyApiKeyRoleAccess, verifyApiKeyUserAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), user.addUserRole ); authenticated.post( "/resource/:resourceId/roles", verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), resource.setResourceRoles ); authenticated.post( "/resource/:resourceId/users", verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), resource.setResourceUsers ); authenticated.post( "/resource/:resourceId/roles/add", verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), resource.addRoleToResource ); authenticated.post( "/resource/:resourceId/roles/remove", verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), resource.removeRoleFromResource ); authenticated.post( "/resource/:resourceId/users/add", verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), resource.addUserToResource ); authenticated.post( "/resource/:resourceId/users/remove", verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), resource.removeUserFromResource ); authenticated.post( `/resource/:resourceId/password`, verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), logActionAudit(ActionsEnum.setResourcePassword), resource.setResourcePassword ); authenticated.post( `/resource/:resourceId/pincode`, verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), logActionAudit(ActionsEnum.setResourcePincode), resource.setResourcePincode ); authenticated.post( `/resource/:resourceId/header-auth`, verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), logActionAudit(ActionsEnum.setResourceHeaderAuth), resource.setResourceHeaderAuth ); authenticated.post( `/resource/:resourceId/whitelist`, verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), logActionAudit(ActionsEnum.setResourceWhitelist), resource.setResourceWhitelist ); authenticated.post( `/resource/:resourceId/whitelist/add`, verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), resource.addEmailToResourceWhitelist ); authenticated.post( `/resource/:resourceId/whitelist/remove`, verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), resource.removeEmailFromResourceWhitelist ); authenticated.get( `/resource/:resourceId/whitelist`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.getResourceWhitelist), resource.getResourceWhitelist ); authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), logActionAudit(ActionsEnum.generateAccessToken), accessToken.generateAccessToken ); authenticated.delete( `/access-token/:accessTokenId`, verifyApiKeyAccessTokenAccess, verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), logActionAudit(ActionsEnum.deleteAcessToken), accessToken.deleteAccessToken ); authenticated.get( `/org/:orgId/access-tokens`, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listAccessTokens), accessToken.listAccessTokens ); authenticated.get( `/resource/:resourceId/access-tokens`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.listAccessTokens), accessToken.listAccessTokens ); authenticated.get( "/org/:orgId/user/:userId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.getOrgUser), user.getOrgUser ); authenticated.get( "/org/:orgId/user-by-username", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.getOrgUser), user.getOrgUserByUsername ); authenticated.post( "/user/:userId/2fa", verifyApiKeyIsRoot, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateUser), logActionAudit(ActionsEnum.updateUser), user.updateUser2FA ); authenticated.get( "/user/:userId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.getUser), user.adminGetUser ); authenticated.get( "/org/:orgId/users", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listUsers), user.listUsers ); authenticated.put( "/org/:orgId/user", verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createOrgUser), logActionAudit(ActionsEnum.createOrgUser), user.createOrgUser ); authenticated.post( "/org/:orgId/user/:userId", verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateOrgUser), logActionAudit(ActionsEnum.updateOrgUser), user.updateOrgUser ); authenticated.delete( "/org/:orgId/user/:userId", verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.removeUser), logActionAudit(ActionsEnum.removeUser), user.removeUserOrg ); // authenticated.put( // "/newt", // verifyApiKeyHasAction(ActionsEnum.createNewt), // newt.createNewt // ); authenticated.get( `/org/:orgId/api-keys`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.listApiKeys), apiKeys.listOrgApiKeys ); authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, verifyApiKeyIsRoot, verifyLimits, verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), logActionAudit(ActionsEnum.setApiKeyActions), apiKeys.setApiKeyActions ); authenticated.get( `/org/:orgId/api-key/:apiKeyId/actions`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.listApiKeyActions), apiKeys.listApiKeyActions ); authenticated.put( `/org/:orgId/api-key`, verifyApiKeyIsRoot, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createApiKey), logActionAudit(ActionsEnum.createApiKey), apiKeys.createOrgApiKey ); authenticated.delete( `/org/:orgId/api-key/:apiKeyId`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteApiKey), logActionAudit(ActionsEnum.deleteApiKey), apiKeys.deleteApiKey ); authenticated.put( "/idp/oidc", verifyApiKeyIsRoot, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createIdp), logActionAudit(ActionsEnum.createIdp), idp.createOidcIdp ); authenticated.post( "/idp/:idpId/oidc", verifyApiKeyIsRoot, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateIdp), logActionAudit(ActionsEnum.updateIdp), idp.updateOidcIdp ); authenticated.get( "/idp", // no guards on this because anyone can list idps for login purposes // we do the same for the external api // verifyApiKeyIsRoot, // verifyApiKeyHasAction(ActionsEnum.listIdps), idp.listIdps ); authenticated.get( "/idp/:idpId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.getIdp), idp.getIdp ); authenticated.put( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createIdpOrg), logActionAudit(ActionsEnum.createIdpOrg), idp.createIdpOrgPolicy ); authenticated.post( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), logActionAudit(ActionsEnum.updateIdpOrg), idp.updateIdpOrgPolicy ); authenticated.delete( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), logActionAudit(ActionsEnum.deleteIdpOrg), idp.deleteIdpOrgPolicy ); authenticated.get( "/idp/:idpId/org", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.listIdpOrgs), idp.listIdpOrgPolicies ); authenticated.get( "/org/:orgId/pick-client-defaults", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createClient), client.pickClientDefaults ); authenticated.get( "/org/:orgId/clients", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listClients), client.listClients ); authenticated.get( "/org/:orgId/user-devices", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listClients), client.listUserDevices ); authenticated.get( "/client/:clientId", verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.getClient), client.getClient ); authenticated.put( "/org/:orgId/client", verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.createClient), logActionAudit(ActionsEnum.createClient), client.createClient ); // authenticated.put( // "/org/:orgId/user/:userId/client", // verifyClientsEnabled, // verifyApiKeyOrgAccess, // verifyApiKeyUserAccess, // verifyApiKeyHasAction(ActionsEnum.createClient), // logActionAudit(ActionsEnum.createClient), // client.createUserClient // ); authenticated.delete( "/client/:clientId", verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.deleteClient), logActionAudit(ActionsEnum.deleteClient), client.deleteClient ); authenticated.post( "/client/:clientId/archive", verifyApiKeyClientAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.archiveClient), logActionAudit(ActionsEnum.archiveClient), client.archiveClient ); authenticated.post( "/client/:clientId/unarchive", verifyApiKeyClientAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.unarchiveClient), logActionAudit(ActionsEnum.unarchiveClient), client.unarchiveClient ); authenticated.post( "/client/:clientId/block", verifyApiKeyClientAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.blockClient), logActionAudit(ActionsEnum.blockClient), client.blockClient ); authenticated.post( "/client/:clientId/unblock", verifyApiKeyClientAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.unblockClient), logActionAudit(ActionsEnum.unblockClient), client.unblockClient ); authenticated.post( "/client/:clientId", verifyApiKeyClientAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateClient), logActionAudit(ActionsEnum.updateClient), client.updateClient ); authenticated.put( "/org/:orgId/blueprint", verifyApiKeyOrgAccess, verifyLimits, verifyApiKeyHasAction(ActionsEnum.applyBlueprint), logActionAudit(ActionsEnum.applyBlueprint), blueprints.applyJSONBlueprint ); authenticated.get( "/org/:orgId/blueprint/:blueprintId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.getBlueprint), blueprints.getBlueprint ); authenticated.get( "/org/:orgId/blueprints", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listBlueprints), blueprints.listBlueprints ); authenticated.get( "/org/:orgId/logs/request", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.viewLogs), logs.queryRequestAuditLogs ); authenticated.get( "/org/:orgId/logs/request/export", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), logs.exportRequestAuditLogs ); authenticated.get( "/org/:orgId/logs/analytics", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.viewLogs), logs.queryRequestAnalytics ); authenticated.get( "/org/:orgId/resource-names", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.listResources), resource.listAllResourceNames ); ================================================ FILE: server/routers/internal.ts ================================================ import { Router } from "express"; import * as gerbil from "@server/routers/gerbil"; import * as traefik from "@server/routers/traefik"; import * as resource from "./resource"; import * as badger from "./badger"; import * as auth from "@server/routers/auth"; import * as supporterKey from "@server/routers/supporterKey"; import * as idp from "@server/routers/idp"; import HttpCode from "@server/types/HttpCode"; import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares"; // Root routes export const internalRouter = Router(); internalRouter.get("/", (_, res) => { res.status(HttpCode.OK).json({ message: "Healthy" }); }); internalRouter.get("/traefik-config", traefik.traefikConfigProvider); internalRouter.get( "/resource-session/:resourceId/:token", auth.checkResourceSession ); internalRouter.post( `/resource/:resourceId/get-exchange-token`, verifySessionUserMiddleware, verifyResourceAccess, resource.getExchangeToken ); internalRouter.get( `/supporter-key/visible`, supporterKey.isSupporterKeyVisible ); internalRouter.get("/idp", idp.listIdps); internalRouter.get("/idp/:idpId", idp.getIdp); // Gerbil routes const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); // Use local gerbil endpoints gerbilRouter.post("/receive-bandwidth", gerbil.receiveBandwidth); gerbilRouter.post("/update-hole-punch", gerbil.updateHolePunch); gerbilRouter.post("/get-all-relays", gerbil.getAllRelays); gerbilRouter.post("/get-resolved-hostname", gerbil.getResolvedHostname); // WE HANDLE THE PROXY INSIDE OF THIS FUNCTION // SO IT REGISTERS THE EXIT NODE LOCALLY AS WELL gerbilRouter.post("/get-config", gerbil.getConfig); // Badger routes const badgerRouter = Router(); internalRouter.use("/badger", badgerRouter); badgerRouter.post("/verify-session", badger.verifyResourceSession); badgerRouter.post("/exchange-session", badger.exchangeSession); ================================================ FILE: server/routers/license/types.ts ================================================ import { LicenseStatus, LicenseKeyCache } from "@server/license/license"; export type ActivateLicenseStatus = LicenseStatus; export type DeleteLicenseKeyResponse = LicenseStatus; export type GetLicenseStatusResponse = LicenseStatus; export type ListLicenseKeysResponse = LicenseKeyCache[]; export type RecheckStatusResponse = LicenseStatus; ================================================ FILE: server/routers/loginPage/types.ts ================================================ import type { LoginPage, LoginPageBranding } from "@server/db"; export type CreateLoginPageResponse = LoginPage; export type DeleteLoginPageResponse = LoginPage; export type GetLoginPageResponse = LoginPage; export type UpdateLoginPageResponse = LoginPage; export type LoadLoginPageResponse = LoginPage & { orgId: string }; export type LoadLoginPageBrandingResponse = LoginPageBranding & { orgId: string; orgName: string; }; export type GetLoginPageBrandingResponse = LoginPageBranding; ================================================ FILE: server/routers/newt/buildConfiguration.ts ================================================ import { clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, ExitNode, resources, Site, siteResources, targetHealthCheck, targets } from "@server/db"; import logger from "@server/logger"; import { initPeerAddHandshake, updatePeer } from "../olm/peers"; import { eq, and } from "drizzle-orm"; import config from "@server/lib/config"; import { generateSubnetProxyTargets, SubnetProxyTarget } from "@server/lib/ip"; export async function buildClientConfigurationForNewtClient( site: Site, exitNode?: ExitNode ) { const siteId = site.siteId; // Get all clients connected to this site const clientsRes = await db .select() .from(clients) .innerJoin( clientSitesAssociationsCache, eq(clients.clientId, clientSitesAssociationsCache.clientId) ) .where(eq(clientSitesAssociationsCache.siteId, siteId)); let peers: Array<{ publicKey: string; allowedIps: string[]; endpoint?: string; }> = []; if (site.publicKey && site.endpoint && exitNode) { // Prepare peers data for the response peers = await Promise.all( clientsRes .filter((client) => { if (!client.clients.pubKey) { logger.warn( `Client ${client.clients.clientId} has no public key, skipping` ); return false; } if (!client.clients.subnet) { logger.warn( `Client ${client.clients.clientId} has no subnet, skipping` ); return false; } return true; }) .map(async (client) => { // Add or update this peer on the olm if it is connected // const allSiteResources = await db // only get the site resources that this client has access to // .select() // .from(siteResources) // .innerJoin( // clientSiteResourcesAssociationsCache, // eq( // siteResources.siteResourceId, // clientSiteResourcesAssociationsCache.siteResourceId // ) // ) // .where( // and( // eq(siteResources.siteId, site.siteId), // eq( // clientSiteResourcesAssociationsCache.clientId, // client.clients.clientId // ) // ) // ); if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm // update the peer info on the olm // if the peer has not been added yet this will be a no-op await updatePeer(client.clients.clientId, { siteId: site.siteId, endpoint: site.endpoint!, relayEndpoint: `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`, publicKey: site.publicKey!, serverIP: site.address, serverPort: site.listenPort // remoteSubnets: generateRemoteSubnets( // allSiteResources.map( // ({ siteResources }) => siteResources // ) // ), // aliases: generateAliasConfig( // allSiteResources.map( // ({ siteResources }) => siteResources // ) // ) }); // also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch // if it has already been added this will be a no-op await initPeerAddHandshake( // this will kick off the add peer process for the client client.clients.clientId, { siteId, exitNode: { publicKey: exitNode.publicKey, endpoint: exitNode.endpoint } } ); } return { publicKey: client.clients.pubKey!, allowedIps: [ `${client.clients.subnet.split("/")[0]}/32` ], // we want to only allow from that client endpoint: client.clientSitesAssociationsCache.isRelayed ? "" : client.clientSitesAssociationsCache.endpoint! // if its relayed it should be localhost }; }) ); } // Filter out any null values from peers that didn't have an olm const validPeers = peers.filter((peer) => peer !== null); // Get all enabled site resources for this site const allSiteResources = await db .select() .from(siteResources) .where(eq(siteResources.siteId, siteId)); const targetsToSend: SubnetProxyTarget[] = []; for (const resource of allSiteResources) { // Get clients associated with this specific resource const resourceClients = await db .select({ clientId: clients.clientId, pubKey: clients.pubKey, subnet: clients.subnet }) .from(clients) .innerJoin( clientSiteResourcesAssociationsCache, eq( clients.clientId, clientSiteResourcesAssociationsCache.clientId ) ) .where( eq( clientSiteResourcesAssociationsCache.siteResourceId, resource.siteResourceId ) ); const resourceTargets = generateSubnetProxyTargets( resource, resourceClients ); targetsToSend.push(...resourceTargets); } return { peers: validPeers, targets: targetsToSend }; } export async function buildTargetConfigurationForNewtClient(siteId: number) { // Get all enabled targets with their resource protocol information const allTargets = await db .select({ resourceId: targets.resourceId, targetId: targets.targetId, ip: targets.ip, method: targets.method, port: targets.port, internalPort: targets.internalPort, enabled: targets.enabled, protocol: resources.protocol, hcEnabled: targetHealthCheck.hcEnabled, hcPath: targetHealthCheck.hcPath, hcScheme: targetHealthCheck.hcScheme, hcMode: targetHealthCheck.hcMode, hcHostname: targetHealthCheck.hcHostname, hcPort: targetHealthCheck.hcPort, hcInterval: targetHealthCheck.hcInterval, hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, hcTimeout: targetHealthCheck.hcTimeout, hcHeaders: targetHealthCheck.hcHeaders, hcMethod: targetHealthCheck.hcMethod, hcTlsServerName: targetHealthCheck.hcTlsServerName, hcStatus: targetHealthCheck.hcStatus }) .from(targets) .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) .leftJoin( targetHealthCheck, eq(targets.targetId, targetHealthCheck.targetId) ) .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); const { tcpTargets, udpTargets } = allTargets.reduce( (acc, target) => { // Filter out invalid targets if (!target.internalPort || !target.ip || !target.port) { return acc; } // Format target into string const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`; // Add to the appropriate protocol array if (target.protocol === "tcp") { acc.tcpTargets.push(formattedTarget); } else { acc.udpTargets.push(formattedTarget); } return acc; }, { tcpTargets: [] as string[], udpTargets: [] as string[] } ); const healthCheckTargets = allTargets.map((target) => { // make sure the stuff is defined if ( !target.hcPath || !target.hcHostname || !target.hcPort || !target.hcInterval || !target.hcMethod ) { // logger.debug( // `Skipping adding target health check ${target.targetId} due to missing health check fields` // ); return null; // Skip targets with missing health check fields } // parse headers const hcHeadersParse = target.hcHeaders ? JSON.parse(target.hcHeaders) : null; const hcHeadersSend: { [key: string]: string } = {}; if (hcHeadersParse) { hcHeadersParse.forEach( (header: { name: string; value: string }) => { hcHeadersSend[header.name] = header.value; } ); } return { id: target.targetId, hcEnabled: target.hcEnabled, hcPath: target.hcPath, hcScheme: target.hcScheme, hcMode: target.hcMode, hcHostname: target.hcHostname, hcPort: target.hcPort, hcInterval: target.hcInterval, // in seconds hcUnhealthyInterval: target.hcUnhealthyInterval, // in seconds hcTimeout: target.hcTimeout, // in seconds hcHeaders: hcHeadersSend, hcMethod: target.hcMethod, hcTlsServerName: target.hcTlsServerName, hcStatus: target.hcStatus }; }); // Filter out any null values from health check targets const validHealthCheckTargets = healthCheckTargets.filter( (target) => target !== null ); return { validHealthCheckTargets, tcpTargets, udpTargets }; } ================================================ FILE: server/routers/newt/createNewt.ts ================================================ import { NextFunction, Request, Response } from "express"; import { db } from "@server/db"; import { hash } from "@node-rs/argon2"; import HttpCode from "@server/types/HttpCode"; import { z } from "zod"; import { newts } from "@server/db"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { SqliteError } from "better-sqlite3"; import moment from "moment"; import { generateSessionToken } from "@server/auth/sessions/app"; import { createNewtSession } from "@server/auth/sessions/newt"; import { fromError } from "zod-validation-error"; import { hashPassword } from "@server/auth/password"; export const createNewtBodySchema = z.object({}); export type CreateNewtBody = z.infer; export type CreateNewtResponse = { token: string; newtId: string; secret: string; }; const createNewtSchema = z.strictObject({ newtId: z.string(), secret: z.string() }); export async function createNewt( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = createNewtSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { newtId, secret } = parsedBody.data; if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); } const secretHash = await hashPassword(secret); await db.insert(newts).values({ newtId: newtId, secretHash, dateCreated: moment().toISOString() }); // give the newt their default permissions: // await db.insert(newtActions).values({ // newtId: newtId, // actionId: ActionsEnum.createOrg, // orgId: null, // }); const token = generateSessionToken(); await createNewtSession(token, newtId); return response(res, { data: { newtId, secret, token }, success: true, error: false, message: "Newt created successfully", status: HttpCode.OK }); } catch (e) { if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { return next( createHttpError( HttpCode.BAD_REQUEST, "A newt with that email address already exists" ) ); } else { console.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create newt" ) ); } } } ================================================ FILE: server/routers/newt/dockerSocket.ts ================================================ import { sendToClient } from "#dynamic/routers/ws"; export function fetchContainers(newtId: string) { const payload = { type: `newt/socket/fetch`, data: {} }; sendToClient(newtId, payload); } export function dockerSocket(newtId: string) { const payload = { type: `newt/socket/check`, data: {} }; sendToClient(newtId, payload); } ================================================ FILE: server/routers/newt/getNewtToken.ts ================================================ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; import { newts } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } 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 { createNewtSession, validateNewtSessionToken } from "@server/auth/sessions/newt"; import { verifyPassword } from "@server/auth/password"; import logger from "@server/logger"; import config from "@server/lib/config"; import { APP_VERSION } from "@server/lib/consts"; export const newtGetTokenBodySchema = z.object({ newtId: z.string(), secret: z.string(), token: z.string().optional() }); export type NewtGetTokenBody = z.infer; export async function getNewtToken( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = newtGetTokenBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { newtId, secret, token } = parsedBody.data; try { if (token) { const { session, newt } = await validateNewtSessionToken(token); if (session) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Newt session already valid. Newt ID: ${newtId}. IP: ${req.ip}.` ); } return response(res, { data: null, success: true, error: false, message: "Token session already valid", status: HttpCode.OK }); } } const existingNewtRes = await db .select() .from(newts) .where(eq(newts.newtId, newtId)); if (!existingNewtRes || !existingNewtRes.length) { return next( createHttpError( HttpCode.BAD_REQUEST, "No newt found with that newtId" ) ); } const existingNewt = existingNewtRes[0]; const validSecret = await verifyPassword( secret, existingNewt.secretHash ); if (!validSecret) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Newt id or secret is incorrect. Newt: ID ${newtId}. IP: ${req.ip}.` ); } return next( createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect") ); } const resToken = generateSessionToken(); await createNewtSession(resToken, existingNewt.newtId); return response<{ token: string; serverVersion: string }>(res, { data: { token: resToken, serverVersion: APP_VERSION }, success: true, error: false, message: "Token created successfully", status: HttpCode.OK }); } catch (e) { console.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to authenticate newt" ) ); } } ================================================ FILE: server/routers/newt/handleApplyBlueprintMessage.ts ================================================ import { db, newts } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db"; import { eq, and, sql, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { applyBlueprint } from "@server/lib/blueprints/applyBlueprint"; export const handleApplyBlueprintMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; logger.debug("Handling apply blueprint message!"); if (!newt) { logger.warn("Newt not found"); return; } if (!newt.siteId) { logger.warn("Newt has no site!"); // TODO: Maybe we create the site here? return; } // get the site const [site] = await db .select() .from(sites) .where(eq(sites.siteId, newt.siteId)); if (!site) { logger.warn("Site not found for newt"); return; } const { blueprint } = message.data; if (!blueprint) { logger.warn("No blueprint provided"); return; } logger.debug(`Received blueprint: ${blueprint}`); try { const blueprintParsed = JSON.parse(blueprint); // Update the blueprint in the database await applyBlueprint({ orgId: site.orgId, configData: blueprintParsed, siteId: site.siteId, source: "NEWT" }); } catch (error) { logger.error(`Failed to update database from config: ${error}`); return { message: { type: "newt/blueprint/results", data: { success: false, message: `Failed to update database from config: ${error}` } }, broadcast: false, // Send to all clients excludeSender: false // Include sender in broadcast }; } return { message: { type: "newt/blueprint/results", data: { success: true, message: "Config updated successfully" } }, broadcast: false, // Send to all clients excludeSender: false // Include sender in broadcast }; }; ================================================ FILE: server/routers/newt/handleGetConfigMessage.ts ================================================ import { z } from "zod"; import { MessageHandler } from "@server/routers/ws"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { db, ExitNode, exitNodes, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; const inputSchema = z.object({ publicKey: z.string(), port: z.int().positive() }); type Input = z.infer; export const handleGetConfigMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; const now = new Date().getTime() / 1000; logger.debug("Handling Newt get config message!"); if (!newt) { logger.warn("Newt not found"); return; } if (!newt.siteId) { logger.warn("Newt has no site!"); // TODO: Maybe we create the site here? return; } const parsed = inputSchema.safeParse(message.data); if (!parsed.success) { logger.error( "handleGetConfigMessage: Invalid input: " + fromError(parsed.error).toString() ); return; } const { publicKey, port } = message.data as Input; const siteId = newt.siteId; // Get the current site data const [existingSite] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)); if (!existingSite) { logger.warn("handleGetConfigMessage: Site not found"); return; } // we need to wait for hole punch success if (!existingSite.endpoint) { logger.debug( `In newt get config: existing site ${existingSite.siteId} has no endpoint, skipping` ); return; } if (existingSite.publicKey !== publicKey) { // TODO: somehow we should make sure a recent hole punch has happened if this occurs (hole punch could be from the last restart if done quickly) } if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) { logger.warn( `handleGetConfigMessage: Site ${existingSite.siteId} last hole punch is too old, skipping` ); return; } // update the endpoint and the public key const [site] = await db .update(sites) .set({ publicKey, listenPort: port }) .where(eq(sites.siteId, siteId)) .returning(); if (!site) { logger.error("handleGetConfigMessage: Failed to update site"); return; } let exitNode: ExitNode | undefined; if (site.exitNodeId) { [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, site.exitNodeId)) .limit(1); if ( exitNode.reachableAt && existingSite.subnet && existingSite.listenPort ) { const payload = { oldDestination: { destinationIP: existingSite.subnet?.split("/")[0], destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }, newDestination: { destinationIP: site.subnet?.split("/")[0], destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated } }; await sendToExitNode(exitNode, { remoteType: "remoteExitNode/update-proxy-mapping", localPath: "/update-proxy-mapping", method: "POST", data: payload }); } } const { peers, targets } = await buildClientConfigurationForNewtClient( site, exitNode ); return { message: { type: "newt/wg/receive-config", data: { ipAddress: site.address, peers, targets } }, options: { compress: canCompress(newt.version, "newt") }, broadcast: false, excludeSender: false }; }; ================================================ FILE: server/routers/newt/handleNewtDisconnectingMessage.ts ================================================ import { MessageHandler } from "@server/routers/ws"; import { db, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; /** * Handles disconnecting messages from sites to show disconnected in the ui */ export const handleNewtDisconnectingMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; const newt = c as Newt; if (!newt) { logger.warn("Newt not found"); return; } if (!newt.siteId) { logger.warn("Newt has no client ID!"); return; } try { // Update the client's last ping timestamp await db .update(sites) .set({ online: false }) .where(eq(sites.siteId, sites.siteId)); } catch (error) { logger.error("Error handling disconnecting message", { error }); } }; ================================================ FILE: server/routers/newt/handleNewtPingMessage.ts ================================================ import { db, newts, sites } from "@server/db"; import { hasActiveConnections, getClientConfigVersion } from "#dynamic/routers/ws"; import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; import { eq, lt, isNull, and, or } from "drizzle-orm"; import logger from "@server/logger"; import { sendNewtSyncMessage } from "./sync"; // Track if the offline checker interval is running let offlineCheckerInterval: NodeJS.Timeout | null = null; const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes /** * Starts the background interval that checks for newt sites that haven't * pinged recently and marks them as offline. For backward compatibility, * a site is only marked offline when there is no active WebSocket connection * either — so older newt versions that don't send pings but remain connected * continue to be treated as online. */ export const startNewtOfflineChecker = (): void => { if (offlineCheckerInterval) { return; // Already running } offlineCheckerInterval = setInterval(async () => { try { const twoMinutesAgo = Math.floor( (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 ); // Find all online newt-type sites that haven't pinged recently // (or have never pinged at all). Join newts to obtain the newtId // needed for the WebSocket connection check. const staleSites = await db .select({ siteId: sites.siteId, newtId: newts.newtId, lastPing: sites.lastPing }) .from(sites) .innerJoin(newts, eq(newts.siteId, sites.siteId)) .where( and( eq(sites.online, true), eq(sites.type, "newt"), or( lt(sites.lastPing, twoMinutesAgo), isNull(sites.lastPing) ) ) ); for (const staleSite of staleSites) { // Backward-compatibility check: if the newt still has an // active WebSocket connection (older clients that don't send // pings), keep the site online. const isConnected = await hasActiveConnections(staleSite.newtId); if (isConnected) { logger.debug( `Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online` ); continue; } logger.info( `Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection` ); await db .update(sites) .set({ online: false }) .where(eq(sites.siteId, staleSite.siteId)); } } catch (error) { logger.error("Error in newt offline checker interval", { error }); } }, OFFLINE_CHECK_INTERVAL); logger.debug("Started newt offline checker interval"); }; /** * Stops the background interval that checks for offline newt sites. */ export const stopNewtOfflineChecker = (): void => { if (offlineCheckerInterval) { clearInterval(offlineCheckerInterval); offlineCheckerInterval = null; logger.info("Stopped newt offline checker interval"); } }; /** * Handles ping messages from newt clients. * * On each ping: * - Marks the associated site as online. * - Records the current timestamp as the newt's last-ping time. * - Triggers a config sync if the newt is running an outdated config version. * - Responds with a pong message. */ export const handleNewtPingMessage: MessageHandler = async (context) => { const { message, client: c } = context; const newt = c as Newt; if (!newt) { logger.warn("Newt ping message: Newt not found"); return; } if (!newt.siteId) { logger.warn("Newt ping message: has no site ID"); return; } try { // Mark the site as online and record the ping timestamp. await db .update(sites) .set({ online: true, lastPing: Math.floor(Date.now() / 1000) }) .where(eq(sites.siteId, newt.siteId)); } catch (error) { logger.error("Error updating online state on newt ping", { error }); } // Check config version and sync if stale. const configVersion = await getClientConfigVersion(newt.newtId); if ( message.configVersion != null && configVersion != null && configVersion !== message.configVersion ) { logger.warn( `Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})` ); const [site] = await db .select() .from(sites) .where(eq(sites.siteId, newt.siteId)) .limit(1); if (!site) { logger.warn( `Newt ping message: site with ID ${newt.siteId} not found` ); return; } await sendNewtSyncMessage(newt, site); } return { message: { type: "pong", data: { timestamp: new Date().toISOString() } }, broadcast: false, excludeSender: false }; }; ================================================ FILE: server/routers/newt/handleNewtPingRequestMessage.ts ================================================ import { db, sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { exitNodes, Newt } from "@server/db"; import logger from "@server/logger"; import { ne, eq, or, and, count } from "drizzle-orm"; import { listExitNodes } from "#dynamic/lib/exitNodes"; export const handleNewtPingRequestMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; logger.info("Handling ping request newt message!"); if (!newt) { logger.warn("Newt not found"); return; } // Get the newt's orgId through the site relationship if (!newt.siteId) { logger.warn("Newt siteId not found"); return; } const [site] = await db .select({ orgId: sites.orgId }) .from(sites) .where(eq(sites.siteId, newt.siteId)) .limit(1); if (!site || !site.orgId) { logger.warn("Site not found"); return; } const { noCloud } = message.data; const exitNodesList = await listExitNodes( site.orgId, true, noCloud || false ); // filter for only the online ones let lastExitNodeId = null; if (newt.siteId) { const [lastExitNode] = await db .select() .from(sites) .where(eq(sites.siteId, newt.siteId)) .limit(1); lastExitNodeId = lastExitNode?.exitNodeId || null; } const exitNodesPayload = await Promise.all( exitNodesList.map(async (node) => { // (MAX_CONNECTIONS - current_connections) / MAX_CONNECTIONS) // higher = more desirable // like saying, this node has x% of its capacity left let weight = 1; const maxConnections = node.maxConnections; if (maxConnections !== null && maxConnections !== undefined) { const [currentConnections] = await db .select({ count: count() }) .from(sites) .where( and( eq(sites.exitNodeId, node.exitNodeId), eq(sites.online, true) ) ); if (currentConnections.count >= maxConnections) { return null; } weight = (maxConnections - currentConnections.count) / maxConnections; } return { exitNodeId: node.exitNodeId, exitNodeName: node.name, endpoint: node.endpoint, weight, wasPreviouslyConnected: node.exitNodeId === lastExitNodeId }; }) ); // filter out null values const filteredExitNodes = exitNodesPayload.filter((node) => node !== null); return { message: { type: "newt/ping/exitNodes", data: { exitNodes: filteredExitNodes } }, broadcast: false, // Send to all clients excludeSender: false // Include sender in broadcast }; }; ================================================ FILE: server/routers/newt/handleNewtRegisterMessage.ts ================================================ import { db, ExitNode, newts, Transaction } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { exitNodes, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { addPeer, deletePeer } from "../gerbil/peers"; import logger from "@server/logger"; import config from "@server/lib/config"; import { findNextAvailableCidr } from "@server/lib/ip"; import { selectBestExitNode, verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { fetchContainers } from "./dockerSocket"; import { lockManager } from "#dynamic/lib/lock"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; export type ExitNodePingResult = { exitNodeId: number; latencyMs: number; weight: number; error?: string; exitNodeName: string; endpoint: string; wasPreviouslyConnected: boolean; }; export const handleNewtRegisterMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; logger.debug("Handling register newt message!"); if (!newt) { logger.warn("Newt not found"); return; } if (!newt.siteId) { logger.warn("Newt has no site!"); // TODO: Maybe we create the site here? return; } const siteId = newt.siteId; const { publicKey, pingResults, newtVersion, backwardsCompatible } = message.data; if (!publicKey) { logger.warn("Public key not provided"); return; } if (backwardsCompatible) { logger.debug( "Backwards compatible mode detecting - not sending connect message and waiting for ping response." ); return; } let exitNodeId: number | undefined; if (pingResults) { const bestPingResult = selectBestExitNode( pingResults as ExitNodePingResult[] ); if (!bestPingResult) { logger.warn("No suitable exit node found based on ping results"); return; } exitNodeId = bestPingResult.exitNodeId; } const [oldSite] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!oldSite) { logger.warn("Site not found"); return; } logger.debug(`Docker socket enabled: ${oldSite.dockerSocketEnabled}`); if (oldSite.dockerSocketEnabled) { logger.debug( "Site has docker socket enabled - requesting docker containers" ); fetchContainers(newt.newtId); } let siteSubnet = oldSite.subnet; let exitNodeIdToQuery = oldSite.exitNodeId; if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { // This effectively moves the exit node to the new one exitNodeIdToQuery = exitNodeId; // Use the provided exitNodeId if it differs from the site's exitNodeId const { exitNode, hasAccess } = await verifyExitNodeOrgAccess( exitNodeIdToQuery, oldSite.orgId ); if (!exitNode) { logger.warn("Exit node not found"); return; } if (!hasAccess) { logger.warn("Not authorized to use this exit node"); return; } const newSubnet = await getUniqueSubnetForSite(exitNode); if (!newSubnet) { logger.error( `No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}` ); return; } siteSubnet = newSubnet; await db .update(sites) .set({ pubKey: publicKey, exitNodeId: exitNodeId, subnet: newSubnet }) .where(eq(sites.siteId, siteId)) .returning(); } else { await db .update(sites) .set({ pubKey: publicKey }) .where(eq(sites.siteId, siteId)) .returning(); } if (!exitNodeIdToQuery) { logger.warn("No exit node ID to query"); return; } const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, exitNodeIdToQuery)) .limit(1); if (oldSite.pubKey && oldSite.pubKey !== publicKey && oldSite.exitNodeId) { logger.info("Public key mismatch. Deleting old peer..."); await deletePeer(oldSite.exitNodeId, oldSite.pubKey); } if (!siteSubnet) { logger.warn("Site has no subnet"); return; } try { // add the peer to the exit node await addPeer(exitNodeIdToQuery, { publicKey: publicKey, allowedIps: [siteSubnet] }); } catch (error) { logger.error(`Failed to add peer to exit node: ${error}`); } if (newtVersion && newtVersion !== newt.version) { // update the newt version in the database await db .update(newts) .set({ version: newtVersion as string }) .where(eq(newts.newtId, newt.newtId)); } if (newtVersion && newtVersion !== newt.version) { // update the newt version in the database await db .update(newts) .set({ version: newtVersion as string }) .where(eq(newts.newtId, newt.newtId)); } const { tcpTargets, udpTargets, validHealthCheckTargets } = await buildTargetConfigurationForNewtClient(siteId); logger.debug( `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` ); return { message: { type: "newt/wg/connect", data: { endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, relayPort: config.getRawConfig().gerbil.clients_start_port, publicKey: exitNode.publicKey, serverIP: exitNode.address.split("/")[0], tunnelIP: siteSubnet.split("/")[0], targets: { udp: udpTargets, tcp: tcpTargets }, healthCheckTargets: validHealthCheckTargets } }, options: { compress: canCompress(newt.version, "newt") }, broadcast: false, // Send to all clients excludeSender: false // Include sender in broadcast }; }; async function getUniqueSubnetForSite( exitNode: ExitNode, trx: Transaction | typeof db = db ): Promise { const lockKey = `subnet-allocation:${exitNode.exitNodeId}`; return await lockManager.withLock( lockKey, async () => { const sitesQuery = await trx .select({ subnet: sites.subnet }) .from(sites) .where(eq(sites.exitNodeId, exitNode.exitNodeId)); const blockSize = config.getRawConfig().gerbil.site_block_size; const subnets = sitesQuery .map((site) => site.subnet) .filter( (subnet) => subnet && /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet) ) .filter((subnet) => subnet !== null); subnets.push(exitNode.address.replace(/\/\d+$/, `/${blockSize}`)); const newSubnet = findNextAvailableCidr( subnets, blockSize, exitNode.address ); return newSubnet; }, 5000 // 5 second lock TTL - subnet allocation should be quick ); } ================================================ FILE: server/routers/newt/handleReceiveBandwidthMessage.ts ================================================ import { db } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients } from "@server/db"; import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; interface PeerBandwidth { publicKey: string; bytesIn: number; bytesOut: number; } interface BandwidthAccumulator { bytesIn: number; bytesOut: number; } // Retry configuration for deadlock handling const MAX_RETRIES = 3; const BASE_DELAY_MS = 50; // How often to flush accumulated bandwidth data to the database const FLUSH_INTERVAL_MS = 120_000; // 120 seconds // In-memory accumulator: publicKey -> { bytesIn, bytesOut } let accumulator = new Map(); /** * Check if an error is a deadlock error */ function isDeadlockError(error: any): boolean { return ( error?.code === "40P01" || error?.cause?.code === "40P01" || (error?.message && error.message.includes("deadlock")) ); } /** * Execute a function with retry logic for deadlock handling */ async function withDeadlockRetry( operation: () => Promise, context: string ): Promise { let attempt = 0; while (true) { try { return await operation(); } catch (error: any) { if (isDeadlockError(error) && attempt < MAX_RETRIES) { attempt++; const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS; const jitter = Math.random() * baseDelay; const delay = baseDelay + jitter; logger.warn( `Deadlock detected in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms` ); await new Promise((resolve) => setTimeout(resolve, delay)); continue; } throw error; } } } /** * Flush all accumulated bandwidth data to the database. * * Swaps out the accumulator before writing so that any bandwidth messages * received during the flush are captured in the new accumulator rather than * being lost or causing contention. Entries that fail to write are re-queued * back into the accumulator so they will be retried on the next flush. * * This function is exported so that the application's graceful-shutdown * cleanup handler can call it before the process exits. */ export async function flushBandwidthToDb(): Promise { if (accumulator.size === 0) { return; } // Atomically swap out the accumulator so new data keeps flowing in // while we write the snapshot to the database. const snapshot = accumulator; accumulator = new Map(); const currentTime = new Date().toISOString(); // Sort by publicKey for consistent lock ordering across concurrent // writers — this is the same deadlock-prevention strategy used in the // original per-message implementation. const sortedEntries = [...snapshot.entries()].sort(([a], [b]) => a.localeCompare(b) ); logger.debug( `Flushing accumulated bandwidth data for ${sortedEntries.length} client(s) to the database` ); for (const [publicKey, { bytesIn, bytesOut }] of sortedEntries) { try { await withDeadlockRetry(async () => { // Use atomic SQL increment to avoid the SELECT-then-UPDATE // anti-pattern and the races it would introduce. await db .update(clients) .set({ // Note: bytesIn from peer goes to megabytesOut (data // sent to client) and bytesOut from peer goes to // megabytesIn (data received from client). megabytesOut: sql`COALESCE(${clients.megabytesOut}, 0) + ${bytesIn}`, megabytesIn: sql`COALESCE(${clients.megabytesIn}, 0) + ${bytesOut}`, lastBandwidthUpdate: currentTime }) .where(eq(clients.pubKey, publicKey)); }, `flush bandwidth for client ${publicKey}`); } catch (error) { logger.error( `Failed to flush bandwidth for client ${publicKey}:`, error ); // Re-queue the failed entry so it is retried on the next flush // rather than silently dropped. const existing = accumulator.get(publicKey); if (existing) { existing.bytesIn += bytesIn; existing.bytesOut += bytesOut; } else { accumulator.set(publicKey, { bytesIn, bytesOut }); } } } } const flushTimer = setInterval(async () => { try { await flushBandwidthToDb(); } catch (error) { logger.error("Unexpected error during periodic bandwidth flush:", error); } }, FLUSH_INTERVAL_MS); // Calling unref() means this timer will not keep the Node.js event loop alive // on its own — the process can still exit normally when there is no other work // left. The graceful-shutdown path (see server/cleanup.ts) will call // flushBandwidthToDb() explicitly before process.exit(), so no data is lost. flushTimer.unref(); export const handleReceiveBandwidthMessage: MessageHandler = async ( context ) => { const { message } = context; if (!message.data.bandwidthData) { logger.warn("No bandwidth data provided"); return; } const bandwidthData: PeerBandwidth[] = message.data.bandwidthData; if (!Array.isArray(bandwidthData)) { throw new Error("Invalid bandwidth data"); } // Accumulate the incoming data in memory; the periodic timer (and the // shutdown hook) will take care of writing it to the database. for (const { publicKey, bytesIn, bytesOut } of bandwidthData) { // Skip peers that haven't transferred any data — writing zeros to the // database would be a no-op anyway. if (bytesIn <= 0 && bytesOut <= 0) { continue; } const existing = accumulator.get(publicKey); if (existing) { existing.bytesIn += bytesIn; existing.bytesOut += bytesOut; } else { accumulator.set(publicKey, { bytesIn, bytesOut }); } } }; ================================================ FILE: server/routers/newt/handleSocketMessages.ts ================================================ import { MessageHandler } from "@server/routers/ws"; import logger from "@server/logger"; import { Newt } from "@server/db"; import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint"; import cache from "#dynamic/lib/cache"; export const handleDockerStatusMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; logger.info("Handling Docker socket check response"); if (!newt) { logger.warn("Newt not found"); return; } logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); const { available, socketPath } = message.data; logger.info( `Docker socket availability for Newt ${newt.newtId}: available=${available}, socketPath=${socketPath}` ); if (available) { logger.info(`Newt ${newt.newtId} has Docker socket access`); await cache.set(`${newt.newtId}:socketPath`, socketPath, 0); await cache.set(`${newt.newtId}:isAvailable`, available, 0); } else { logger.warn(`Newt ${newt.newtId} does not have Docker socket access`); } return; }; export const handleDockerContainersMessage: MessageHandler = async ( context ) => { const { message, client, sendToClient } = context; const newt = client as Newt; logger.info("Handling Docker containers response"); if (!newt) { logger.warn("Newt not found"); return; } logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`); const { containers } = message.data; logger.info( `Docker containers for Newt ${newt.newtId}: ${containers ? containers.length : 0}` ); if (containers && containers.length > 0) { await cache.set(`${newt.newtId}:dockerContainers`, containers, 0); } else { logger.warn(`Newt ${newt.newtId} does not have Docker containers`); } if (!newt.siteId) { logger.warn("Newt has no site!"); return; } await applyNewtDockerBlueprint(newt.siteId, newt.newtId, containers); }; ================================================ FILE: server/routers/newt/index.ts ================================================ export * from "./createNewt"; export * from "./getNewtToken"; export * from "./handleNewtRegisterMessage"; export * from "./handleReceiveBandwidthMessage"; export * from "./handleGetConfigMessage"; export * from "./handleSocketMessages"; export * from "./handleNewtPingRequestMessage"; export * from "./handleApplyBlueprintMessage"; export * from "./handleNewtPingMessage"; export * from "./handleNewtDisconnectingMessage"; ================================================ FILE: server/routers/newt/peers.ts ================================================ import { db, Site } from "@server/db"; import { newts, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; export async function addPeer( siteId: number, peer: { publicKey: string; allowedIps: string[]; endpoint: string; }, newtId?: string ) { let site: Site | null = null; if (!newtId) { [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { throw new Error(`Site with ID ${siteId} not found`); } // get the newt on the site const [newt] = await db .select() .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); if (!newt) { throw new Error(`Site found for site ${siteId}`); } newtId = newt.newtId; } await sendToClient(newtId, { type: "newt/wg/peer/add", data: peer }, { incrementConfigVersion: true }).catch((error) => { logger.warn(`Error sending message:`, error); }); logger.info(`Added peer ${peer.publicKey} to newt ${newtId}`); return site; } export async function deletePeer( siteId: number, publicKey: string, newtId?: string ) { let site: Site | null = null; if (!newtId) { [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { throw new Error(`Site with ID ${siteId} not found`); } // get the newt on the site const [newt] = await db .select() .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); if (!newt) { throw new Error(`Newt not found for site ${siteId}`); } newtId = newt.newtId; } await sendToClient(newtId, { type: "newt/wg/peer/remove", data: { publicKey } }, { incrementConfigVersion: true }).catch((error) => { logger.warn(`Error sending message:`, error); }); logger.info(`Deleted peer ${publicKey} from newt ${newtId}`); return site; } export async function updatePeer( siteId: number, publicKey: string, peer: { allowedIps?: string[]; endpoint?: string; }, newtId?: string ) { let site: Site | null = null; if (!newtId) { [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { throw new Error(`Site with ID ${siteId} not found`); } // get the newt on the site const [newt] = await db .select() .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); if (!newt) { throw new Error(`Newt not found for site ${siteId}`); } newtId = newt.newtId; } await sendToClient(newtId, { type: "newt/wg/peer/update", data: { publicKey, ...peer } }, { incrementConfigVersion: true }).catch((error) => { logger.warn(`Error sending message:`, error); }); logger.info(`Updated peer ${publicKey} on newt ${newtId}`); return site; } ================================================ FILE: server/routers/newt/sync.ts ================================================ import { ExitNode, exitNodes, Newt, Site, db } from "@server/db"; import { eq } from "drizzle-orm"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { buildClientConfigurationForNewtClient, buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { canCompress } from "@server/lib/clientVersionChecks"; export async function sendNewtSyncMessage(newt: Newt, site: Site) { const { tcpTargets, udpTargets, validHealthCheckTargets } = await buildTargetConfigurationForNewtClient(site.siteId); let exitNode: ExitNode | undefined; if (site.exitNodeId) { [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, site.exitNodeId)) .limit(1); } const { peers, targets } = await buildClientConfigurationForNewtClient( site, exitNode ); await sendToClient( newt.newtId, { type: "newt/sync", data: { proxyTargets: { udp: udpTargets, tcp: tcpTargets }, healthCheckTargets: validHealthCheckTargets, peers: peers, clientTargets: targets } }, { compress: canCompress(newt.version, "newt") } ).catch((error) => { logger.warn(`Error sending newt sync message:`, error); }); } ================================================ FILE: server/routers/newt/targets.ts ================================================ import { Target, TargetHealthCheck, db, targetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { eq, inArray } from "drizzle-orm"; import { canCompress } from "@server/lib/clientVersionChecks"; export async function addTargets( newtId: string, targets: Target[], healthCheckData: TargetHealthCheck[], protocol: string, version?: string | null ) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { return `${target.internalPort ? target.internalPort + ":" : ""}${ target.ip }:${target.port}`; }); await sendToClient(newtId, { type: `newt/${protocol}/add`, data: { targets: payloadTargets } }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); // Create a map for quick lookup const healthCheckMap = new Map(); healthCheckData.forEach((hc) => { healthCheckMap.set(hc.targetId, hc); }); const healthCheckTargets = targets.map((target) => { const hc = healthCheckMap.get(target.targetId); // If no health check data found, skip this target if (!hc) { logger.warn( `No health check configuration found for target ${target.targetId}` ); return null; } // Ensure all necessary fields are present if ( !hc.hcPath || !hc.hcHostname || !hc.hcPort || !hc.hcInterval || !hc.hcMethod ) { logger.debug( `Skipping target ${target.targetId} due to missing health check fields` ); return null; // Skip targets with missing health check fields } const hcHeadersParse = hc.hcHeaders ? JSON.parse(hc.hcHeaders) : null; const hcHeadersSend: { [key: string]: string } = {}; if (hcHeadersParse) { // transform hcHeadersParse.forEach( (header: { name: string; value: string }) => { hcHeadersSend[header.name] = header.value; } ); } // try to parse the hcStatus into a int and if not possible set to undefined let hcStatus: number | undefined = undefined; if (hc.hcStatus) { const parsedStatus = parseInt(hc.hcStatus.toString()); if (!isNaN(parsedStatus)) { hcStatus = parsedStatus; } } return { id: target.targetId, hcEnabled: hc.hcEnabled, hcPath: hc.hcPath, hcScheme: hc.hcScheme, hcMode: hc.hcMode, hcHostname: hc.hcHostname, hcPort: hc.hcPort, hcInterval: hc.hcInterval, // in seconds hcUnhealthyInterval: hc.hcUnhealthyInterval, // in seconds hcTimeout: hc.hcTimeout, // in seconds hcHeaders: hcHeadersSend, hcMethod: hc.hcMethod, hcStatus: hcStatus, hcTlsServerName: hc.hcTlsServerName }; }); // Filter out any null values from health check targets const validHealthCheckTargets = healthCheckTargets.filter( (target) => target !== null ); await sendToClient(newtId, { type: `newt/healthcheck/add`, data: { targets: validHealthCheckTargets } }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); } export async function removeTargets( newtId: string, targets: Target[], protocol: string, version?: string | null ) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { return `${target.internalPort ? target.internalPort + ":" : ""}${ target.ip }:${target.port}`; }); await sendToClient(newtId, { type: `newt/${protocol}/remove`, data: { targets: payloadTargets } }, { incrementConfigVersion: true }); const healthCheckTargets = targets.map((target) => { return target.targetId; }); await sendToClient(newtId, { type: `newt/healthcheck/remove`, data: { ids: healthCheckTargets } }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); } ================================================ FILE: server/routers/olm/archiveUserOlm.ts ================================================ import { NextFunction, Request, Response } from "express"; import { db } from "@server/db"; import { olms } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; const paramsSchema = z .object({ userId: z.string(), olmId: z.string() }) .strict(); export async function archiveUserOlm( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { olmId } = parsedParams.data; await db.transaction(async (trx) => { await trx .update(olms) .set({ archived: true }) .where(eq(olms.olmId, olmId)); }); return response(res, { data: null, success: true, error: false, message: "Device archived successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to archive device" ) ); } } ================================================ FILE: server/routers/olm/buildConfiguration.ts ================================================ import { Client, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, exitNodes, siteResources, sites } from "@server/db"; import { Alias, generateAliasConfig, generateRemoteSubnets } from "@server/lib/ip"; import logger from "@server/logger"; import { and, eq } from "drizzle-orm"; import { addPeer, deletePeer } from "../newt/peers"; import config from "@server/lib/config"; export async function buildSiteConfigurationForOlmClient( client: Client, publicKey: string | null, relay: boolean, jitMode: boolean = false ) { const siteConfigurations: { siteId: number; name?: string endpoint?: string publicKey?: string serverIP?: string | null serverPort?: number | null remoteSubnets?: string[]; aliases: Alias[]; }[] = []; // Get all sites data const sitesData = await db .select() .from(sites) .innerJoin( clientSitesAssociationsCache, eq(sites.siteId, clientSitesAssociationsCache.siteId) ) .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); // Process each site for (const { sites: site, clientSitesAssociationsCache: association } of sitesData) { const allSiteResources = await db // only get the site resources that this client has access to .select() .from(siteResources) .innerJoin( clientSiteResourcesAssociationsCache, eq( siteResources.siteResourceId, clientSiteResourcesAssociationsCache.siteResourceId ) ) .where( and( eq(siteResources.siteId, site.siteId), eq( clientSiteResourcesAssociationsCache.clientId, client.clientId ) ) ); if (jitMode) { // Add site configuration to the array siteConfigurations.push({ siteId: site.siteId, // remoteSubnets: generateRemoteSubnets( // allSiteResources.map(({ siteResources }) => siteResources) // ), aliases: generateAliasConfig( allSiteResources.map(({ siteResources }) => siteResources) ) }); continue; } if (!site.exitNodeId) { logger.warn( `Site ${site.siteId} does not have exit node, skipping` ); continue; } // Validate endpoint and hole punch status if (!site.endpoint) { logger.warn( `In olm register: site ${site.siteId} has no endpoint, skipping` ); continue; } if (!site.publicKey || site.publicKey == "") { // the site is not ready to accept new peers logger.warn( `Site ${site.siteId} has no public key, skipping` ); continue; } // if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) { // logger.warn( // `Site ${site.siteId} last hole punch is too old, skipping` // ); // continue; // } // If public key changed, delete old peer from this site if (client.pubKey && client.pubKey != publicKey) { logger.info( `Public key mismatch. Deleting old peer from site ${site.siteId}...` ); await deletePeer(site.siteId, client.pubKey!); } if (!site.subnet) { logger.warn(`Site ${site.siteId} has no subnet, skipping`); continue; } const [clientSite] = await db .select() .from(clientSitesAssociationsCache) .where( and( eq(clientSitesAssociationsCache.clientId, client.clientId), eq(clientSitesAssociationsCache.siteId, site.siteId) ) ) .limit(1); // Add the peer to the exit node for this site if (clientSite.endpoint && publicKey) { logger.info( `Adding peer ${publicKey} to site ${site.siteId} with endpoint ${clientSite.endpoint}` ); await addPeer(site.siteId, { publicKey: publicKey, allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client endpoint: relay ? "" : clientSite.endpoint }); } else { logger.warn( `Client ${client.clientId} has no endpoint, skipping peer addition` ); } let relayEndpoint: string | undefined = undefined; if (relay) { const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, site.exitNodeId)) .limit(1); if (!exitNode) { logger.warn(`Exit node not found for site ${site.siteId}`); continue; } relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`; } // Add site configuration to the array siteConfigurations.push({ siteId: site.siteId, name: site.name, // relayEndpoint: relayEndpoint, // this can be undefined now if not relayed // lets not do this for now because it would conflict with the hole punch testing endpoint: site.endpoint, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, remoteSubnets: generateRemoteSubnets( allSiteResources.map(({ siteResources }) => siteResources) ), aliases: generateAliasConfig( allSiteResources.map(({ siteResources }) => siteResources) ) }); } return siteConfigurations; } ================================================ FILE: server/routers/olm/createOlm.ts ================================================ import { NextFunction, Request, Response } from "express"; import { db } from "@server/db"; import { hash } from "@node-rs/argon2"; import HttpCode from "@server/types/HttpCode"; import { z } from "zod"; import { newts } from "@server/db"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { SqliteError } from "better-sqlite3"; import moment from "moment"; import { generateSessionToken } from "@server/auth/sessions/app"; import { createNewtSession } from "@server/auth/sessions/newt"; import { fromError } from "zod-validation-error"; import { hashPassword } from "@server/auth/password"; export const createNewtBodySchema = z.object({}); export type CreateNewtBody = z.infer; export type CreateNewtResponse = { token: string; newtId: string; secret: string; }; const createNewtSchema = z.strictObject({ newtId: z.string(), secret: z.string() }); export async function createNewt( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = createNewtSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { newtId, secret } = parsedBody.data; if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); } const secretHash = await hashPassword(secret); await db.insert(newts).values({ newtId: newtId, secretHash, dateCreated: moment().toISOString() }); // give the newt their default permissions: // await db.insert(newtActions).values({ // newtId: newtId, // actionId: ActionsEnum.createOrg, // orgId: null, // }); const token = generateSessionToken(); await createNewtSession(token, newtId); return response(res, { data: { newtId, secret, token }, success: true, error: false, message: "Newt created successfully", status: HttpCode.OK }); } catch (e) { if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { return next( createHttpError( HttpCode.BAD_REQUEST, "A newt with that email address already exists" ) ); } else { console.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create newt" ) ); } } } ================================================ FILE: server/routers/olm/createUserOlm.ts ================================================ import { NextFunction, Request, Response } from "express"; import { db, olms } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import { z } from "zod"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import moment from "moment"; import { generateId } from "@server/auth/sessions/app"; import { fromError } from "zod-validation-error"; import { hashPassword } from "@server/auth/password"; import { OpenAPITags, registry } from "@server/openApi"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; const bodySchema = z .object({ name: z.string().min(1).max(255) }) .strict(); const paramsSchema = z.object({ userId: z.string() }); export type CreateOlmBody = z.infer; export type CreateOlmResponse = { olmId: string; secret: string; }; // registry.registerPath({ // method: "put", // path: "/user/{userId}/olm", // description: "Create a new olm for a user.", // tags: [OpenAPITags.User, OpenAPITags.Client], // request: { // body: { // content: { // "application/json": { // schema: bodySchema // } // } // }, // params: paramsSchema // }, // responses: {} // }); export async function createUserOlm( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { name } = parsedBody.data; const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId } = parsedParams.data; const olmId = generateId(15); const secret = generateId(48); const secretHash = await hashPassword(secret); await db.transaction(async (trx) => { await trx.insert(olms).values({ olmId: olmId, userId, name, secretHash, dateCreated: moment().toISOString() }); await calculateUserClientsForOrgs(userId, trx); }); return response(res, { data: { olmId, secret }, success: true, error: false, message: "Olm created successfully", status: HttpCode.OK }); } catch (e) { console.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create olm" ) ); } } ================================================ FILE: server/routers/olm/deleteUserOlm.ts ================================================ import { NextFunction, Request, Response } from "express"; import { Client, db } from "@server/db"; import { olms, clients, clientSitesAssociationsCache } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "../client/terminate"; import { OlmErrorCodes } from "./error"; const paramsSchema = z .object({ userId: z.string(), olmId: z.string() }) .strict(); // registry.registerPath({ // method: "delete", // path: "/user/{userId}/olm/{olmId}", // description: "Delete an olm for a user.", // tags: [OpenAPITags.User, OpenAPITags.Client], // request: { // params: paramsSchema // }, // responses: {} // }); export async function deleteUserOlm( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { olmId } = parsedParams.data; // Delete associated clients and the OLM in a transaction await db.transaction(async (trx) => { // Find all clients associated with this OLM const associatedClients = await trx .select({ clientId: clients.clientId }) .from(clients) .where(eq(clients.olmId, olmId)); let deletedClient: Client | null = null; // Delete all associated clients if (associatedClients.length > 0) { [deletedClient] = await trx .delete(clients) .where(eq(clients.olmId, olmId)) .returning(); } // Finally, delete the OLM itself const [olm] = await trx .delete(olms) .where(eq(olms.olmId, olmId)) .returning(); if (deletedClient) { await rebuildClientAssociationsFromClient(deletedClient, trx); if (olm) { await sendTerminateClient( deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, olm.olmId ); // the olmId needs to be provided because it cant look it up after deletion } } }); return response(res, { data: null, success: true, error: false, message: "Device deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to delete device" ) ); } } ================================================ FILE: server/routers/olm/error.ts ================================================ import { sendToClient } from "#dynamic/routers/ws"; // Error codes for registration failures export const OlmErrorCodes = { OLM_NOT_FOUND: { code: "OLM_NOT_FOUND", message: "The specified device could not be found." }, CLIENT_ID_NOT_FOUND: { code: "CLIENT_ID_NOT_FOUND", message: "No client ID was provided in the request." }, CLIENT_NOT_FOUND: { code: "CLIENT_NOT_FOUND", message: "The specified client does not exist." }, CLIENT_BLOCKED: { code: "CLIENT_BLOCKED", message: "This client has been blocked in this organization and cannot connect. Please contact your administrator." }, CLIENT_PENDING: { code: "CLIENT_PENDING", message: "This client is pending approval and cannot connect yet. Please contact your administrator." }, ORG_NOT_FOUND: { code: "ORG_NOT_FOUND", message: "The organization could not be found. Please select a valid organization." }, USER_ID_NOT_FOUND: { code: "USER_ID_NOT_FOUND", message: "No user ID was provided in the request." }, INVALID_USER_SESSION: { code: "INVALID_USER_SESSION", message: "Your user session is invalid or has expired. Please log in again." }, USER_ID_MISMATCH: { code: "USER_ID_MISMATCH", message: "The provided user ID does not match the session." }, ORG_ACCESS_POLICY_DENIED: { code: "ORG_ACCESS_POLICY_DENIED", message: "Access to this organization has been denied by policy. Please contact your administrator." }, ORG_ACCESS_POLICY_PASSWORD_EXPIRED: { code: "ORG_ACCESS_POLICY_PASSWORD_EXPIRED", message: "Access to this organization has been denied because your password has expired. Please visit this organization's dashboard to update your password." }, ORG_ACCESS_POLICY_SESSION_EXPIRED: { code: "ORG_ACCESS_POLICY_SESSION_EXPIRED", message: "Access to this organization has been denied because your session has expired. Please log in again to refresh the session." }, ORG_ACCESS_POLICY_2FA_REQUIRED: { code: "ORG_ACCESS_POLICY_2FA_REQUIRED", message: "Access to this organization requires two-factor authentication. Please visit this organization's dashboard to enable two-factor authentication." }, TERMINATED_REKEYED: { code: "TERMINATED_REKEYED", message: "This session was terminated because encryption keys were regenerated." }, TERMINATED_ORG_DELETED: { code: "TERMINATED_ORG_DELETED", message: "This session was terminated because the organization was deleted." }, TERMINATED_INACTIVITY: { code: "TERMINATED_INACTIVITY", message: "This session was terminated due to inactivity." }, TERMINATED_DELETED: { code: "TERMINATED_DELETED", message: "This session was terminated because it was deleted." }, TERMINATED_ARCHIVED: { code: "TERMINATED_ARCHIVED", message: "This session was terminated because it was archived." }, TERMINATED_BLOCKED: { code: "TERMINATED_BLOCKED", message: "This session was terminated because access was blocked." } } as const; // Helper function to send registration error export async function sendOlmError( error: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes], olmId: string ) { sendToClient(olmId, { type: "olm/error", data: { code: error.code, message: error.message } }); } ================================================ FILE: server/routers/olm/fingerprintingUtils.ts ================================================ import { sha256 } from "@oslojs/crypto/sha2"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { currentFingerprint, db, fingerprintSnapshots, Olm } from "@server/db"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; import { desc, eq, lt } from "drizzle-orm"; function fingerprintSnapshotHash(fingerprint: any, postures: any): string { const canonical = { username: fingerprint.username ?? null, hostname: fingerprint.hostname ?? null, platform: fingerprint.platform ?? null, osVersion: fingerprint.osVersion ?? null, kernelVersion: fingerprint.kernelVersion ?? null, arch: fingerprint.arch ?? null, deviceModel: fingerprint.deviceModel ?? null, serialNumber: fingerprint.serialNumber ?? null, platformFingerprint: fingerprint.platformFingerprint ?? null, biometricsEnabled: postures.biometricsEnabled ?? false, diskEncrypted: postures.diskEncrypted ?? false, firewallEnabled: postures.firewallEnabled ?? false, autoUpdatesEnabled: postures.autoUpdatesEnabled ?? false, tpmAvailable: postures.tpmAvailable ?? false, windowsAntivirusEnabled: postures.windowsAntivirusEnabled ?? false, macosSipEnabled: postures.macosSipEnabled ?? false, macosGatekeeperEnabled: postures.macosGatekeeperEnabled ?? false, macosFirewallStealthMode: postures.macosFirewallStealthMode ?? false, linuxAppArmorEnabled: postures.linuxAppArmorEnabled ?? false, linuxSELinuxEnabled: postures.linuxSELinuxEnabled ?? false }; return encodeHexLowerCase( sha256(new TextEncoder().encode(JSON.stringify(canonical))) ); } export async function handleFingerprintInsertion( olm: Olm, fingerprint: any, postures: any ) { if ( !olm?.olmId || !fingerprint || !postures || Object.keys(fingerprint).length === 0 || Object.keys(postures).length === 0 ) { return; } const now = Math.floor(Date.now() / 1000); const hash = fingerprintSnapshotHash(fingerprint, postures); const [current] = await db .select() .from(currentFingerprint) .where(eq(currentFingerprint.olmId, olm.olmId)) .limit(1); if (!current) { const [inserted] = await db .insert(currentFingerprint) .values({ olmId: olm.olmId, firstSeen: now, lastSeen: now, lastCollectedAt: now, // fingerprint username: fingerprint.username, hostname: fingerprint.hostname, platform: fingerprint.platform, osVersion: fingerprint.osVersion, kernelVersion: fingerprint.kernelVersion, arch: fingerprint.arch, deviceModel: fingerprint.deviceModel, serialNumber: fingerprint.serialNumber, platformFingerprint: fingerprint.platformFingerprint, biometricsEnabled: postures.biometricsEnabled, diskEncrypted: postures.diskEncrypted, firewallEnabled: postures.firewallEnabled, autoUpdatesEnabled: postures.autoUpdatesEnabled, tpmAvailable: postures.tpmAvailable, windowsAntivirusEnabled: postures.windowsAntivirusEnabled, macosSipEnabled: postures.macosSipEnabled, macosGatekeeperEnabled: postures.macosGatekeeperEnabled, macosFirewallStealthMode: postures.macosFirewallStealthMode, linuxAppArmorEnabled: postures.linuxAppArmorEnabled, linuxSELinuxEnabled: postures.linuxSELinuxEnabled }) .returning(); await db.insert(fingerprintSnapshots).values({ fingerprintId: inserted.fingerprintId, username: fingerprint.username, hostname: fingerprint.hostname, platform: fingerprint.platform, osVersion: fingerprint.osVersion, kernelVersion: fingerprint.kernelVersion, arch: fingerprint.arch, deviceModel: fingerprint.deviceModel, serialNumber: fingerprint.serialNumber, platformFingerprint: fingerprint.platformFingerprint, biometricsEnabled: postures.biometricsEnabled, diskEncrypted: postures.diskEncrypted, firewallEnabled: postures.firewallEnabled, autoUpdatesEnabled: postures.autoUpdatesEnabled, tpmAvailable: postures.tpmAvailable, windowsAntivirusEnabled: postures.windowsAntivirusEnabled, macosSipEnabled: postures.macosSipEnabled, macosGatekeeperEnabled: postures.macosGatekeeperEnabled, macosFirewallStealthMode: postures.macosFirewallStealthMode, linuxAppArmorEnabled: postures.linuxAppArmorEnabled, linuxSELinuxEnabled: postures.linuxSELinuxEnabled, hash, collectedAt: now }); return; } const [latestSnapshot] = await db .select({ hash: fingerprintSnapshots.hash }) .from(fingerprintSnapshots) .where(eq(fingerprintSnapshots.fingerprintId, current.fingerprintId)) .orderBy(desc(fingerprintSnapshots.collectedAt)) .limit(1); const changed = !latestSnapshot || latestSnapshot.hash !== hash; if (changed) { await db.insert(fingerprintSnapshots).values({ fingerprintId: current.fingerprintId, username: fingerprint.username, hostname: fingerprint.hostname, platform: fingerprint.platform, osVersion: fingerprint.osVersion, kernelVersion: fingerprint.kernelVersion, arch: fingerprint.arch, deviceModel: fingerprint.deviceModel, serialNumber: fingerprint.serialNumber, platformFingerprint: fingerprint.platformFingerprint, biometricsEnabled: postures.biometricsEnabled, diskEncrypted: postures.diskEncrypted, firewallEnabled: postures.firewallEnabled, autoUpdatesEnabled: postures.autoUpdatesEnabled, tpmAvailable: postures.tpmAvailable, windowsAntivirusEnabled: postures.windowsAntivirusEnabled, macosSipEnabled: postures.macosSipEnabled, macosGatekeeperEnabled: postures.macosGatekeeperEnabled, macosFirewallStealthMode: postures.macosFirewallStealthMode, linuxAppArmorEnabled: postures.linuxAppArmorEnabled, linuxSELinuxEnabled: postures.linuxSELinuxEnabled, hash, collectedAt: now }); await db .update(currentFingerprint) .set({ lastSeen: now, lastCollectedAt: now, username: fingerprint.username, hostname: fingerprint.hostname, platform: fingerprint.platform, osVersion: fingerprint.osVersion, kernelVersion: fingerprint.kernelVersion, arch: fingerprint.arch, deviceModel: fingerprint.deviceModel, serialNumber: fingerprint.serialNumber, platformFingerprint: fingerprint.platformFingerprint, biometricsEnabled: postures.biometricsEnabled, diskEncrypted: postures.diskEncrypted, firewallEnabled: postures.firewallEnabled, autoUpdatesEnabled: postures.autoUpdatesEnabled, tpmAvailable: postures.tpmAvailable, windowsAntivirusEnabled: postures.windowsAntivirusEnabled, macosSipEnabled: postures.macosSipEnabled, macosGatekeeperEnabled: postures.macosGatekeeperEnabled, macosFirewallStealthMode: postures.macosFirewallStealthMode, linuxAppArmorEnabled: postures.linuxAppArmorEnabled, linuxSELinuxEnabled: postures.linuxSELinuxEnabled }) .where(eq(currentFingerprint.fingerprintId, current.fingerprintId)); } else { await db .update(currentFingerprint) .set({ lastSeen: now }) .where(eq(currentFingerprint.fingerprintId, current.fingerprintId)); } } export async function cleanUpOldFingerprintSnapshots(retentionDays: number) { const cutoff = calculateCutoffTimestamp(retentionDays); await db .delete(fingerprintSnapshots) .where(lt(fingerprintSnapshots.collectedAt, cutoff)); } ================================================ FILE: server/routers/olm/getOlmToken.ts ================================================ import { generateSessionToken, validateSessionToken } from "@server/auth/sessions/app"; import { clients, db, ExitNode, exitNodes, sites, clientSitesAssociationsCache } from "@server/db"; import { olms } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { and, eq, inArray } 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 { createOlmSession, validateOlmSessionToken } from "@server/auth/sessions/olm"; import { verifyPassword } from "@server/auth/password"; import logger from "@server/logger"; import config from "@server/lib/config"; import { APP_VERSION } from "@server/lib/consts"; export const olmGetTokenBodySchema = z.object({ olmId: z.string(), secret: z.string().optional(), userToken: z.string().optional(), token: z.string().optional(), // this is the olm token orgId: z.string().optional() }); export type OlmGetTokenBody = z.infer; export async function getOlmToken( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = olmGetTokenBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { olmId, secret, token, orgId, userToken } = parsedBody.data; try { if (token) { const { session, olm } = await validateOlmSessionToken(token); if (session) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Olm session already valid. Olm ID: ${olmId}. IP: ${req.ip}.` ); } return response(res, { data: null, success: true, error: false, message: "Token session already valid", status: HttpCode.OK }); } } const [existingOlm] = await db .select() .from(olms) .where(eq(olms.olmId, olmId)); if (!existingOlm) { return next( createHttpError( HttpCode.BAD_REQUEST, "No olm found with that olmId" ) ); } if (userToken) { const { session: userSession, user } = await validateSessionToken(userToken); if (!userSession || !user) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid user token") ); } if (user.userId !== existingOlm.userId) { return next( createHttpError( HttpCode.BAD_REQUEST, "User token does not match olm" ) ); } } else if (secret) { // this is for backward compatibility, we want to move towards userToken but some old clients may still be using secret so we will support both for now const validSecret = await verifyPassword( secret, existingOlm.secretHash ); if (!validSecret) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Olm id or secret is incorrect. Olm: ID ${olmId}. IP: ${req.ip}.` ); } return next( createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect") ); } } else { return next( createHttpError( HttpCode.BAD_REQUEST, "Either secret or userToken is required" ) ); } logger.debug("Creating new olm session token"); const resToken = generateSessionToken(); await createOlmSession(resToken, existingOlm.olmId); let clientIdToUse; if (orgId) { // we did provide the org const [client] = await db .select() .from(clients) .where(and(eq(clients.orgId, orgId), eq(clients.olmId, olmId))) // we want to lock on to the client with this olmId otherwise it can get assigned to a random one .limit(1); if (!client) { return next( createHttpError( HttpCode.BAD_REQUEST, "No client found for provided orgId" ) ); } if (existingOlm.clientId !== client.clientId) { // we only need to do this if the client is changing logger.debug( `Switching olm client ${existingOlm.olmId} to org ${orgId} for user ${existingOlm.userId}` ); await db .update(olms) .set({ clientId: client.clientId }) .where(eq(olms.olmId, existingOlm.olmId)); } clientIdToUse = client.clientId; } else { if (!existingOlm.clientId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Olm is not associated with a client, orgId is required" ) ); } const [client] = await db .select() .from(clients) .where(eq(clients.clientId, existingOlm.clientId)) .limit(1); if (!client) { return next( createHttpError( HttpCode.BAD_REQUEST, "Olm's associated client not found, orgId is required" ) ); } clientIdToUse = client.clientId; } // Get all exit nodes from sites where the client has peers const clientSites = await db .select() .from(clientSitesAssociationsCache) .innerJoin( sites, eq(sites.siteId, clientSitesAssociationsCache.siteId) ) .where(eq(clientSitesAssociationsCache.clientId, clientIdToUse!)); // Extract unique exit node IDs const exitNodeIds = Array.from( new Set( clientSites .map(({ sites: site }) => site.exitNodeId) .filter((id): id is number => id !== null) ) ); let allExitNodes: ExitNode[] = []; if (exitNodeIds.length > 0) { allExitNodes = await db .select() .from(exitNodes) .where(inArray(exitNodes.exitNodeId, exitNodeIds)); } // Map exitNodeId to siteIds const exitNodeIdToSiteIds: Record = {}; for (const { sites: site } of clientSites) { if (site.exitNodeId !== null) { if (!exitNodeIdToSiteIds[site.exitNodeId]) { exitNodeIdToSiteIds[site.exitNodeId] = []; } exitNodeIdToSiteIds[site.exitNodeId].push(site.siteId); } } const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => { return { publicKey: exitNode.publicKey, relayPort: config.getRawConfig().gerbil.clients_start_port, endpoint: exitNode.endpoint, siteIds: exitNodeIdToSiteIds[exitNode.exitNodeId] ?? [] }; }); logger.debug("Token created successfully"); return response<{ token: string; exitNodes: { publicKey: string; endpoint: string }[]; serverVersion: string; }>(res, { data: { token: resToken, exitNodes: exitNodesHpData, serverVersion: APP_VERSION }, success: true, error: false, message: "Token created successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to authenticate olm" ) ); } } ================================================ FILE: server/routers/olm/getUserOlm.ts ================================================ import { NextFunction, Request, Response } from "express"; import { db } from "@server/db"; import { olms, clients, currentFingerprint } from "@server/db"; import { eq, and } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { getUserDeviceName } from "@server/db/names"; // import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z .object({ userId: z.string(), olmId: z.string() }) .strict(); const querySchema = z.object({ orgId: z.string().optional() }); // registry.registerPath({ // method: "get", // path: "/user/{userId}/olm/{olmId}", // description: "Get an olm for a user.", // tags: [OpenAPITags.User, OpenAPITags.Client], // request: { // params: paramsSchema // }, // responses: {} // }); export async function getUserOlm( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { olmId, userId } = parsedParams.data; const { orgId } = parsedQuery.data; const [result] = await db .select() .from(olms) .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))) .leftJoin( currentFingerprint, eq(olms.olmId, currentFingerprint.olmId) ) .limit(1); if (!result || !result.olms) { return next(createHttpError(HttpCode.NOT_FOUND, "Olm not found")); } const olm = result.olms; // If orgId is provided and olm has a clientId, fetch the client to check blocked status let blocked: boolean | undefined; if (orgId && olm.clientId) { const [client] = await db .select({ blocked: clients.blocked }) .from(clients) .where( and( eq(clients.clientId, olm.clientId), eq(clients.orgId, orgId) ) ) .limit(1); blocked = client?.blocked ?? false; } // Replace name with device name const model = result.currentFingerprint?.deviceModel || null; const newName = getUserDeviceName(model, olm.name); const responseData = blocked !== undefined ? { ...olm, name: newName, blocked } : { ...olm, name: newName }; return response(res, { data: responseData, success: true, error: false, message: "Successfully retrieved olm", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to retrieve olm" ) ); } } ================================================ FILE: server/routers/olm/handleOlmDisconnectingMessage.ts ================================================ import { MessageHandler } from "@server/routers/ws"; import { clients, db, Olm } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; /** * Handles disconnecting messages from clients to show disconnected in the ui */ export const handleOlmDisconnectingMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; const olm = c as Olm; if (!olm) { logger.warn("Olm not found"); return; } if (!olm.clientId) { logger.warn("Olm has no client ID!"); return; } try { // Update the client's last ping timestamp await db .update(clients) .set({ online: false }) .where(eq(clients.clientId, olm.clientId)); } catch (error) { logger.error("Error handling disconnecting message", { error }); } }; ================================================ FILE: server/routers/olm/handleOlmPingMessage.ts ================================================ import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws"; import { db } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, olms, Olm } from "@server/db"; import { eq, lt, isNull, and, or } from "drizzle-orm"; import logger from "@server/logger"; import { validateSessionToken } from "@server/auth/sessions/app"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { sendTerminateClient } from "../client/terminate"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { sendOlmSyncMessage } from "./sync"; import { OlmErrorCodes } from "./error"; import { handleFingerprintInsertion } from "./fingerprintingUtils"; // Track if the offline checker interval is running let offlineCheckerInterval: NodeJS.Timeout | null = null; const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes /** * Starts the background interval that checks for clients that haven't pinged recently * and marks them as offline */ export const startOlmOfflineChecker = (): void => { if (offlineCheckerInterval) { return; // Already running } offlineCheckerInterval = setInterval(async () => { try { const twoMinutesAgo = Math.floor( (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 ); // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING // Find clients that haven't pinged in the last 2 minutes and mark them as offline const offlineClients = await db .update(clients) .set({ online: false }) .where( and( eq(clients.online, true), or( lt(clients.lastPing, twoMinutesAgo), isNull(clients.lastPing) ) ) ) .returning(); for (const offlineClient of offlineClients) { logger.info( `Kicking offline olm client ${offlineClient.clientId} due to inactivity` ); if (!offlineClient.olmId) { logger.warn( `Offline client ${offlineClient.clientId} has no olmId, cannot disconnect` ); continue; } // Send a disconnect message to the client if connected try { await sendTerminateClient( offlineClient.clientId, OlmErrorCodes.TERMINATED_INACTIVITY, offlineClient.olmId ); // terminate first // wait a moment to ensure the message is sent await new Promise((resolve) => setTimeout(resolve, 1000)); await disconnectClient(offlineClient.olmId); } catch (error) { logger.error( `Error sending disconnect to offline olm ${offlineClient.clientId}`, { error } ); } } } catch (error) { logger.error("Error in offline checker interval", { error }); } }, OFFLINE_CHECK_INTERVAL); logger.debug("Started offline checker interval"); }; /** * Stops the background interval that checks for offline clients */ export const stopOlmOfflineChecker = (): void => { if (offlineCheckerInterval) { clearInterval(offlineCheckerInterval); offlineCheckerInterval = null; logger.info("Stopped offline checker interval"); } }; /** * Handles ping messages from clients and responds with pong */ export const handleOlmPingMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; const olm = c as Olm; const { userToken, fingerprint, postures } = message.data; if (!olm) { logger.warn("Olm not found"); return; } if (!olm.clientId) { logger.warn("Olm has no client ID!"); return; } const isUserDevice = olm.userId !== null && olm.userId !== undefined; try { // get the client const [client] = await db .select() .from(clients) .where(eq(clients.clientId, olm.clientId)) .limit(1); if (!client) { logger.warn("Client not found for olm ping"); return; } if (client.blocked) { // NOTE: by returning we dont update the lastPing, so the offline checker will eventually disconnect them logger.debug( `Blocked client ${client.clientId} attempted olm ping` ); return; } if (olm.userId) { // we need to check a user token to make sure its still valid const { session: userSession, user } = await validateSessionToken(userToken); if (!userSession || !user) { logger.warn("Invalid user session for olm ping"); return; // by returning here we just ignore the ping and the setInterval will force it to disconnect } if (user.userId !== olm.userId) { logger.warn("User ID mismatch for olm ping"); return; } if (user.userId !== client.userId) { logger.warn("Client user ID mismatch for olm ping"); return; } const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(userToken)) ); const policyCheck = await checkOrgAccessPolicy({ orgId: client.orgId, userId: olm.userId, sessionId // this is the user token passed in the message }); if (!policyCheck.allowed) { logger.warn( `Olm user ${olm.userId} does not pass access policies for org ${client.orgId}: ${policyCheck.error}` ); return; } } // get the version logger.debug( `handleOlmPingMessage: About to get config version for olmId: ${olm.olmId}` ); const configVersion = await getClientConfigVersion(olm.olmId); logger.debug( `handleOlmPingMessage: Got config version: ${configVersion} (type: ${typeof configVersion})` ); if (configVersion == null || configVersion === undefined) { logger.debug( `handleOlmPingMessage: could not get config version from server for olmId: ${olm.olmId}` ); } if ( message.configVersion != null && configVersion != null && configVersion != message.configVersion ) { logger.debug( `handleOlmPingMessage: Olm ping with outdated config version: ${message.configVersion} (current: ${configVersion})` ); await sendOlmSyncMessage(olm, client); } // Update the client's last ping timestamp await db .update(clients) .set({ lastPing: Math.floor(Date.now() / 1000), online: true, archived: false }) .where(eq(clients.clientId, olm.clientId)); if (olm.archived) { await db .update(olms) .set({ archived: false }) .where(eq(olms.olmId, olm.olmId)); } } catch (error) { logger.error("Error handling ping message", { error }); } if (isUserDevice) { await handleFingerprintInsertion(olm, fingerprint, postures); } return { message: { type: "pong", data: { timestamp: new Date().toISOString() } }, broadcast: false, excludeSender: false }; }; ================================================ FILE: server/routers/olm/handleOlmRegisterMessage.ts ================================================ import { db, orgs } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, Olm, olms, sites } from "@server/db"; import { count, eq } from "drizzle-orm"; import logger from "@server/logger"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { validateSessionToken } from "@server/auth/sessions/app"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { getUserDeviceName } from "@server/db/names"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { OlmErrorCodes, sendOlmError } from "./error"; import { handleFingerprintInsertion } from "./fingerprintingUtils"; import { Alias } from "@server/lib/ip"; import { build } from "@server/build"; import { canCompress } from "@server/lib/clientVersionChecks"; export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.info("Handling register olm message!"); const { message, client: c, sendToClient } = context; const olm = c as Olm; const now = Math.floor(Date.now() / 1000); if (!olm) { logger.warn("Olm not found"); return; } const { publicKey, relay, olmVersion, olmAgent, orgId, userToken, fingerprint, postures } = message.data; if (!olm.clientId) { logger.warn("Olm client ID not found"); sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId); return; } logger.debug("Handling fingerprint insertion for olm register...", { olmId: olm.olmId, fingerprint, postures }); const isUserDevice = olm.userId !== null && olm.userId !== undefined; if (isUserDevice) { await handleFingerprintInsertion(olm, fingerprint, postures); } if ( (olmVersion && olm.version !== olmVersion) || (olmAgent && olm.agent !== olmAgent) || olm.archived ) { await db .update(olms) .set({ version: olmVersion, agent: olmAgent, archived: false }) .where(eq(olms.olmId, olm.olmId)); } const [client] = await db .select() .from(clients) .where(eq(clients.clientId, olm.clientId)) .limit(1); if (!client) { logger.warn("Client ID not found"); sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId); return; } if (client.blocked) { logger.debug( `Client ${client.clientId} is blocked. Ignoring register.` ); sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId); return; } if (client.approvalState == "pending") { logger.debug( `Client ${client.clientId} approval is pending. Ignoring register.` ); sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId); return; } const deviceModel = fingerprint?.deviceModel ?? null; const computedName = getUserDeviceName(deviceModel, client.name); if (computedName && computedName !== client.name) { await db .update(clients) .set({ name: computedName }) .where(eq(clients.clientId, client.clientId)); } if (computedName && computedName !== olm.name) { await db .update(olms) .set({ name: computedName }) .where(eq(olms.olmId, olm.olmId)); } const [org] = await db .select() .from(orgs) .where(eq(orgs.orgId, client.orgId)) .limit(1); if (!org) { logger.warn("Org not found"); sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId); return; } if (orgId) { if (!olm.userId) { logger.warn("Olm has no user ID"); sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId); return; } const { session: userSession, user } = await validateSessionToken(userToken); if (!userSession || !user) { logger.warn("Invalid user session for olm register"); sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId); return; } if (user.userId !== olm.userId) { logger.warn("User ID mismatch for olm register"); sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId); return; } const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(userToken)) ); const policyCheck = await checkOrgAccessPolicy({ orgId: orgId, userId: olm.userId, sessionId // this is the user token passed in the message }); logger.debug("Policy check result:", policyCheck); if (policyCheck?.error) { logger.error( `Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}` ); sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); return; } if (policyCheck.policies?.passwordAge?.compliant === false) { logger.warn( `Olm user ${olm.userId} has non-compliant password age for org ${orgId}` ); sendOlmError( OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED, olm.olmId ); return; } else if ( policyCheck.policies?.maxSessionLength?.compliant === false ) { logger.warn( `Olm user ${olm.userId} has non-compliant session length for org ${orgId}` ); sendOlmError( OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED, olm.olmId ); return; } else if (policyCheck.policies?.requiredTwoFactor === false) { logger.warn( `Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}` ); sendOlmError( OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED, olm.olmId ); return; } else if (!policyCheck.allowed) { logger.warn( `Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}` ); sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); return; } } // Get all sites data const sitesCountResult = await db .select({ count: count() }) .from(sites) .innerJoin( clientSitesAssociationsCache, eq(sites.siteId, clientSitesAssociationsCache.siteId) ) .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); // Extract the count value from the result array const sitesCount = sitesCountResult.length > 0 ? sitesCountResult[0].count : 0; // Prepare an array to store site configurations logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`); let jitMode = false; if (sitesCount > 250 && build == "saas") { // THIS IS THE MAX ON THE BUSINESS TIER // we have too many sites // If we have too many sites we need to drop into fully JIT mode by not sending any of the sites logger.info("Too many sites (%d), dropping into JIT mode", sitesCount); jitMode = true; } logger.debug( `Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}` ); if (!publicKey) { logger.warn("Public key not provided"); return; } if (client.pubKey !== publicKey || client.archived) { logger.info( "Public key mismatch. Updating public key and clearing session info..." ); // Update the client's public key await db .update(clients) .set({ pubKey: publicKey, archived: false }) .where(eq(clients.clientId, client.clientId)); // set isRelay to false for all of the client's sites to reset the connection metadata await db .update(clientSitesAssociationsCache) .set({ isRelayed: relay == true, isJitMode: jitMode }) .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); } // this prevents us from accepting a register from an olm that has not hole punched yet. // the olm will pump the register so we can keep checking // TODO: I still think there is a better way to do this rather than locking it out here but ??? if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) { logger.warn( "Client last hole punch is too old and we have sites to send; skipping this register" ); return; } // NOTE: its important that the client here is the old client and the public key is the new key const siteConfigurations = await buildSiteConfigurationForOlmClient( client, publicKey, relay, jitMode ); // Return connect message with all site configurations return { message: { type: "olm/wg/connect", data: { sites: siteConfigurations, tunnelIP: client.subnet, utilitySubnet: org.utilitySubnet } }, options: { compress: canCompress(olm.version, "olm") }, broadcast: false, excludeSender: false }; }; ================================================ FILE: server/routers/olm/handleOlmRelayMessage.ts ================================================ import { db, exitNodes, sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; import { and, eq } from "drizzle-orm"; import { updatePeer as newtUpdatePeer } from "../newt/peers"; import logger from "@server/logger"; import config from "@server/lib/config"; export const handleOlmRelayMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; const olm = c as Olm; logger.info("Handling relay olm message!"); if (!olm) { logger.warn("Olm not found"); return; } if (!olm.clientId) { logger.warn("Olm has no client!"); return; } const clientId = olm.clientId; const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { logger.warn("Client not found"); return; } // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old if (!client.pubKey) { logger.warn("Client has no endpoint or listen port"); return; } const { siteId, chainId } = message.data; // Get the site const [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site || !site.exitNodeId) { logger.warn("Site not found or has no exit node"); return; } // get the site's exit node const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, site.exitNodeId)) .limit(1); if (!exitNode) { logger.warn("Exit node not found for site"); return; } await db .update(clientSitesAssociationsCache) .set({ isRelayed: true }) .where( and( eq(clientSitesAssociationsCache.clientId, olm.clientId), eq(clientSitesAssociationsCache.siteId, siteId) ) ); // update the peer on the exit node await newtUpdatePeer(siteId, client.pubKey, { endpoint: "" // this removes the endpoint so the exit node knows to relay }); return { message: { type: "olm/wg/peer/relay", data: { siteId: siteId, relayEndpoint: exitNode.endpoint, relayPort: config.getRawConfig().gerbil.clients_start_port, chainId } }, broadcast: false, excludeSender: false }; }; ================================================ FILE: server/routers/olm/handleOlmServerInitAddPeerHandshake.ts ================================================ import { clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, exitNodes, Site, siteResources } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, Olm, sites } from "@server/db"; import { and, eq, or } from "drizzle-orm"; import logger from "@server/logger"; import { initPeerAddHandshake } from "./peers"; export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( context ) => { logger.info("Handling register olm message!"); const { message, client: c, sendToClient } = context; const olm = c as Olm; if (!olm) { logger.warn("Olm not found"); return; } if (!olm.clientId) { logger.warn("Olm has no client!"); // TODO: Maybe we create the site here? return; } const clientId = olm.clientId; const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { logger.warn("Client not found"); return; } const { siteId, resourceId, chainId } = message.data; let site: Site | null = null; if (siteId) { // get the site const [siteRes] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (siteRes) { site = siteRes; } } if (resourceId && !site) { const resources = await db .select() .from(siteResources) .where( and( or( eq(siteResources.niceId, resourceId), eq(siteResources.alias, resourceId) ), eq(siteResources.orgId, client.orgId) ) ); if (!resources || resources.length === 0) { logger.error(`handleOlmServerPeerAddMessage: Resource not found`); // cancel the request from the olm side to not keep doing this await sendToClient( olm.olmId, { type: "olm/wg/peer/chain/cancel", data: { chainId } }, { incrementConfigVersion: false } ).catch((error) => { logger.warn(`Error sending message:`, error); }); return; } 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 logger.error( `handleOlmServerPeerAddMessage: Multiple resources found matching the criteria` ); return; } const resource = resources[0]; const currentResourceAssociationCaches = await db .select() .from(clientSiteResourcesAssociationsCache) .where( and( eq( clientSiteResourcesAssociationsCache.siteResourceId, resource.siteResourceId ), eq( clientSiteResourcesAssociationsCache.clientId, client.clientId ) ) ); if (currentResourceAssociationCaches.length === 0) { logger.error( `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` ); // cancel the request from the olm side to not keep doing this await sendToClient( olm.olmId, { type: "olm/wg/peer/chain/cancel", data: { chainId } }, { incrementConfigVersion: false } ).catch((error) => { logger.warn(`Error sending message:`, error); }); return; } const siteIdFromResource = resource.siteId; // get the site const [siteRes] = await db .select() .from(sites) .where(eq(sites.siteId, siteIdFromResource)); if (!siteRes) { logger.error( `handleOlmServerPeerAddMessage: Site with ID ${site} not found` ); return; } site = siteRes; } if (!site) { logger.error(`handleOlmServerPeerAddMessage: Site not found`); return; } // check if the client can access this site using the cache const currentSiteAssociationCaches = await db .select() .from(clientSitesAssociationsCache) .where( and( eq(clientSitesAssociationsCache.clientId, client.clientId), eq(clientSitesAssociationsCache.siteId, site.siteId) ) ); if (currentSiteAssociationCaches.length === 0) { logger.error( `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}` ); // cancel the request from the olm side to not keep doing this await sendToClient( olm.olmId, { type: "olm/wg/peer/chain/cancel", data: { chainId } }, { incrementConfigVersion: false } ).catch((error) => { logger.warn(`Error sending message:`, error); }); return; } if (!site.exitNodeId) { logger.error( `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` ); // cancel the request from the olm side to not keep doing this await sendToClient( olm.olmId, { type: "olm/wg/peer/chain/cancel", data: { chainId } }, { incrementConfigVersion: false } ).catch((error) => { logger.warn(`Error sending message:`, error); }); return; } // get the exit node from the side const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, site.exitNodeId)); if (!exitNode) { logger.error( `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` ); return; } // also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch // if it has already been added this will be a no-op await initPeerAddHandshake( // this will kick off the add peer process for the client client.clientId, { siteId: site.siteId, exitNode: { publicKey: exitNode.publicKey, endpoint: exitNode.endpoint } }, olm.olmId, chainId ); return; }; ================================================ FILE: server/routers/olm/handleOlmServerPeerAddMessage.ts ================================================ import { Client, clientSiteResourcesAssociationsCache, db, ExitNode, Org, orgs, roleClients, roles, siteResources, Transaction, userClients, userOrgs, users } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, exitNodes, Olm, olms, sites } from "@server/db"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import { addPeer, deletePeer } from "../newt/peers"; import logger from "@server/logger"; import { listExitNodes } from "#dynamic/lib/exitNodes"; import { generateAliasConfig, getNextAvailableClientSubnet } from "@server/lib/ip"; import { generateRemoteSubnets } from "@server/lib/ip"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { validateSessionToken } from "@server/auth/sessions/app"; import config from "@server/lib/config"; import { addPeer as newtAddPeer, deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; export const handleOlmServerPeerAddMessage: MessageHandler = async ( context ) => { logger.info("Handling register olm message!"); const { message, client: c, sendToClient } = context; const olm = c as Olm; const now = Math.floor(Date.now() / 1000); if (!olm) { logger.warn("Olm not found"); return; } const { siteId, chainId } = message.data; // get the site const [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { logger.error( `handleOlmServerPeerAddMessage: Site with ID ${siteId} not found` ); return; } if (!site.endpoint) { logger.error( `handleOlmServerPeerAddMessage: Site with ID ${siteId} has no endpoint` ); return; } // get the client if (!olm.clientId) { logger.error( `handleOlmServerPeerAddMessage: Olm with ID ${olm.olmId} has no clientId` ); return; } const [client] = await db .select() .from(clients) .where(and(eq(clients.clientId, olm.clientId))) .limit(1); if (!client) { logger.error( `handleOlmServerPeerAddMessage: Client with ID ${olm.clientId} not found` ); return; } if (!client.pubKey) { logger.error( `handleOlmServerPeerAddMessage: Client with ID ${client.clientId} has no public key` ); return; } let endpoint: string | null = null; // TODO: should we pick only the one from the site its talking to instead of any good current session? const currentSessionSiteAssociationCaches = await db .select() .from(clientSitesAssociationsCache) .where( and( eq(clientSitesAssociationsCache.clientId, client.clientId), isNotNull(clientSitesAssociationsCache.endpoint), eq(clientSitesAssociationsCache.publicKey, client.pubKey) // limit it to the current session its connected with otherwise the endpoint could be stale ) ); // pick an endpoint for (const assoc of currentSessionSiteAssociationCaches) { if (assoc.endpoint) { endpoint = assoc.endpoint; break; } } if (!endpoint) { logger.error( `handleOlmServerPeerAddMessage: No endpoint found for client ${client.clientId}` ); return; } // NOTE: here we are always starting direct to the peer and will relay later await newtAddPeer(siteId, { publicKey: client.pubKey, allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client endpoint: endpoint // this is the client's endpoint with reference to the site's exit node }); const allSiteResources = await db // only get the site resources that this client has access to .select() .from(siteResources) .innerJoin( clientSiteResourcesAssociationsCache, eq( siteResources.siteResourceId, clientSiteResourcesAssociationsCache.siteResourceId ) ) .where( and( eq(siteResources.siteId, site.siteId), eq( clientSiteResourcesAssociationsCache.clientId, client.clientId ) ) ); // Return connect message with all site configurations return { message: { type: "olm/wg/peer/add", data: { siteId: site.siteId, name: site.name, endpoint: site.endpoint, publicKey: site.publicKey, serverIP: site.address, serverPort: site.listenPort, remoteSubnets: generateRemoteSubnets( allSiteResources.map(({ siteResources }) => siteResources) ), aliases: generateAliasConfig( allSiteResources.map(({ siteResources }) => siteResources) ), chainId: chainId, } }, broadcast: false, excludeSender: false }; }; ================================================ FILE: server/routers/olm/handleOlmUnRelayMessage.ts ================================================ import { db, exitNodes, sites } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; import { and, eq } from "drizzle-orm"; import { updatePeer as newtUpdatePeer } from "../newt/peers"; import logger from "@server/logger"; export const handleOlmUnRelayMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; const olm = c as Olm; logger.info("Handling unrelay olm message!"); if (!olm) { logger.warn("Olm not found"); return; } if (!olm.clientId) { logger.warn("Olm has no client!"); return; } const clientId = olm.clientId; const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { logger.warn("Client not found"); return; } // make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old if (!client.pubKey) { logger.warn("Client has no endpoint or listen port"); return; } const { siteId, chainId } = message.data; // Get the site const [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { logger.warn("Site not found or has no exit node"); return; } const [clientSiteAssociation] = await db .update(clientSitesAssociationsCache) .set({ isRelayed: false }) .where( and( eq(clientSitesAssociationsCache.clientId, olm.clientId), eq(clientSitesAssociationsCache.siteId, siteId) ) ) .returning(); if (!clientSiteAssociation) { logger.warn("Client-Site association not found"); return; } if (!clientSiteAssociation.endpoint) { logger.warn("Client-Site association has no endpoint, cannot unrelay"); return; } // update the peer on the exit node await newtUpdatePeer(siteId, client.pubKey, { endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the exit node }); return { message: { type: "olm/wg/peer/unrelay", data: { siteId: siteId, endpoint: site.endpoint, chainId } }, broadcast: false, excludeSender: false }; }; ================================================ FILE: server/routers/olm/index.ts ================================================ export * from "./handleOlmRegisterMessage"; export * from "./getOlmToken"; export * from "./createUserOlm"; export * from "./handleOlmRelayMessage"; export * from "./handleOlmPingMessage"; export * from "./archiveUserOlm"; export * from "./unarchiveUserOlm"; export * from "./listUserOlms"; export * from "./getUserOlm"; export * from "./handleOlmServerPeerAddMessage"; export * from "./handleOlmUnRelayMessage"; export * from "./recoverOlmWithFingerprint"; export * from "./handleOlmDisconnectingMessage"; export * from "./handleOlmServerInitAddPeerHandshake"; ================================================ FILE: server/routers/olm/listUserOlms.ts ================================================ import { NextFunction, Request, Response } from "express"; import { db, currentFingerprint } from "@server/db"; import { olms } from "@server/db"; import { eq, count, desc } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import { getUserDeviceName } from "@server/db/names"; const querySchema = z.object({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.number().int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.number().int().nonnegative()) }); const paramsSchema = z .object({ userId: z.string() }) .strict(); // registry.registerPath({ // method: "delete", // path: "/user/{userId}/olms", // description: "List all olms for a user.", // tags: [OpenAPITags.User, OpenAPITags.Client], // request: { // query: querySchema, // params: paramsSchema // }, // responses: {} // }); export type ListUserOlmsResponse = { olms: Array<{ olmId: string; dateCreated: string; version: string | null; name: string | null; clientId: number | null; userId: string | null; archived: boolean; }>; pagination: { total: number; limit: number; offset: number; }; }; export async function listUserOlms( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId } = parsedParams.data; // Get total count (including archived OLMs) const [totalCountResult] = await db .select({ count: count() }) .from(olms) .where(eq(olms.userId, userId)); const total = totalCountResult?.count || 0; // Get OLMs for the current user (including archived OLMs) const list = await db .select() .from(olms) .where(eq(olms.userId, userId)) .leftJoin( currentFingerprint, eq(olms.olmId, currentFingerprint.olmId) ) .orderBy(desc(olms.dateCreated)) .limit(limit) .offset(offset); const userOlms = list.map((item) => { const model = item.currentFingerprint?.deviceModel || null; const newName = getUserDeviceName(model, item.olms.name); return { olmId: item.olms.olmId, dateCreated: item.olms.dateCreated, version: item.olms.version, name: newName, clientId: item.olms.clientId, userId: item.olms.userId, archived: item.olms.archived }; }); return response(res, { data: { olms: userOlms, pagination: { total, limit, offset } }, success: true, error: false, message: "Olms retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to list OLMs" ) ); } } ================================================ FILE: server/routers/olm/peers.ts ================================================ import { sendToClient } from "#dynamic/routers/ws"; import { clientSitesAssociationsCache, db, olms } from "@server/db"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; import logger from "@server/logger"; import { and, eq } from "drizzle-orm"; import { Alias } from "yaml"; export async function addPeer( clientId: number, peer: { siteId: number; name: string; publicKey: string; endpoint: string; relayEndpoint: string; serverIP: string | null; serverPort: number | null; remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access aliases: Alias[]; }, olmId?: string, version?: string | null ) { if (!olmId) { const [olm] = await db .select() .from(olms) .where(eq(olms.clientId, clientId)) .limit(1); if (!olm) { return; // ignore this because an olm might not be associated with the client anymore } olmId = olm.olmId; version = olm.version; } await sendToClient( olmId, { type: "olm/wg/peer/add", data: { siteId: peer.siteId, name: peer.name, publicKey: peer.publicKey, endpoint: peer.endpoint, relayEndpoint: peer.relayEndpoint, serverIP: peer.serverIP, serverPort: peer.serverPort, remoteSubnets: peer.remoteSubnets, // optional, comma-separated list of subnets that this site can access aliases: peer.aliases } }, { incrementConfigVersion: true, compress: canCompress(version, "olm") } ).catch((error) => { logger.warn(`Error sending message:`, error); }); logger.info(`Added peer ${peer.publicKey} to olm ${olmId}`); } export async function deletePeer( clientId: number, siteId: number, publicKey: string, olmId?: string, version?: string | null ) { if (!olmId) { const [olm] = await db .select() .from(olms) .where(eq(olms.clientId, clientId)) .limit(1); if (!olm) { return; } olmId = olm.olmId; version = olm.version; } await sendToClient( olmId, { type: "olm/wg/peer/remove", data: { publicKey, siteId: siteId } }, { incrementConfigVersion: true, compress: canCompress(version, "olm") } ).catch((error) => { logger.warn(`Error sending message:`, error); }); logger.info(`Deleted peer ${publicKey} from olm ${olmId}`); } export async function updatePeer( clientId: number, peer: { siteId: number; publicKey: string; endpoint: string; relayEndpoint?: string; serverIP?: string | null; serverPort?: number | null; remoteSubnets?: string[] | null; // optional, comma-separated list of subnets that aliases?: Alias[] | null; }, olmId?: string, version?: string | null ) { if (!olmId) { const [olm] = await db .select() .from(olms) .where(eq(olms.clientId, clientId)) .limit(1); if (!olm) { return; } olmId = olm.olmId; version = olm.version; } await sendToClient( olmId, { type: "olm/wg/peer/update", data: { siteId: peer.siteId, publicKey: peer.publicKey, endpoint: peer.endpoint, relayEndpoint: peer.relayEndpoint, serverIP: peer.serverIP, serverPort: peer.serverPort, remoteSubnets: peer.remoteSubnets, aliases: peer.aliases } }, { incrementConfigVersion: true, compress: canCompress(version, "olm") } ).catch((error) => { logger.warn(`Error sending message:`, error); }); logger.info(`Updated peer ${peer.publicKey} on olm ${olmId}`); } export async function initPeerAddHandshake( clientId: number, peer: { siteId: number; exitNode: { publicKey: string; endpoint: string; }; }, olmId?: string, chainId?: string ) { if (!olmId) { const [olm] = await db .select() .from(olms) .where(eq(olms.clientId, clientId)) .limit(1); if (!olm) { return; } olmId = olm.olmId; } await sendToClient( olmId, { type: "olm/wg/peer/holepunch/site/add", data: { siteId: peer.siteId, exitNode: { publicKey: peer.exitNode.publicKey, relayPort: config.getRawConfig().gerbil.clients_start_port, endpoint: peer.exitNode.endpoint }, chainId } }, { incrementConfigVersion: true } ).catch((error) => { logger.warn(`Error sending message:`, error); }); // update the clientSiteAssociationsCache to make the isJitMode flag false so that JIT mode is disabled for this site if it restarts or something after the connection await db .update(clientSitesAssociationsCache) .set({ isJitMode: false }) .where( and( eq(clientSitesAssociationsCache.clientId, clientId), eq(clientSitesAssociationsCache.siteId, peer.siteId) ) ); logger.info( `Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}` ); } ================================================ FILE: server/routers/olm/recoverOlmWithFingerprint.ts ================================================ import { db, currentFingerprint, olms } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { and, eq } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import response from "@server/lib/response"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { generateId } from "@server/auth/sessions/app"; import { hashPassword } from "@server/auth/password"; const paramsSchema = z .object({ userId: z.string() }) .strict(); const bodySchema = z .object({ platformFingerprint: z.string() }) .strict(); export async function recoverOlmWithFingerprint( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId } = parsedParams.data; const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { platformFingerprint } = parsedBody.data; const result = await db .select({ olm: olms, fingerprint: currentFingerprint }) .from(olms) .innerJoin( currentFingerprint, eq(currentFingerprint.olmId, olms.olmId) ) .where( and( eq(olms.userId, userId), eq( currentFingerprint.platformFingerprint, platformFingerprint ) ) ) .orderBy(currentFingerprint.lastSeen); if (!result || result.length == 0) { return next( createHttpError( HttpCode.NOT_FOUND, "corresponding olm with this fingerprint not found" ) ); } if (result.length > 1) { return next( createHttpError( HttpCode.CONFLICT, "multiple matching fingerprints found, not resetting secrets" ) ); } const [{ olm: foundOlm }] = result; const newSecret = generateId(48); const newSecretHash = await hashPassword(newSecret); await db .update(olms) .set({ secretHash: newSecretHash }) .where(eq(olms.olmId, foundOlm.olmId)); return response(res, { data: { olmId: foundOlm.olmId, secret: newSecret }, success: true, error: false, message: "Successfully retrieved olm", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to recover olm using provided fingerprint input" ) ); } } ================================================ FILE: server/routers/olm/sync.ts ================================================ import { Client, db, exitNodes, Olm, sites, clientSitesAssociationsCache } from "@server/db"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { eq, inArray } from "drizzle-orm"; import config from "@server/lib/config"; import { canCompress } from "@server/lib/clientVersionChecks"; export async function sendOlmSyncMessage(olm: Olm, client: Client) { // NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT const siteConfigurations = await buildSiteConfigurationForOlmClient( client, client.pubKey, false ); // Get all exit nodes from sites where the client has peers const clientSites = await db .select() .from(clientSitesAssociationsCache) .innerJoin(sites, eq(sites.siteId, clientSitesAssociationsCache.siteId)) .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); // Extract unique exit node IDs const exitNodeIds = Array.from( new Set( clientSites .map(({ sites: site }) => site.exitNodeId) .filter((id): id is number => id !== null) ) ); let exitNodesData: { publicKey: string; relayPort: number; endpoint: string; siteIds: number[]; }[] = []; if (exitNodeIds.length > 0) { const allExitNodes = await db .select() .from(exitNodes) .where(inArray(exitNodes.exitNodeId, exitNodeIds)); // Map exitNodeId to siteIds const exitNodeIdToSiteIds: Record = {}; for (const { sites: site } of clientSites) { if (site.exitNodeId !== null) { if (!exitNodeIdToSiteIds[site.exitNodeId]) { exitNodeIdToSiteIds[site.exitNodeId] = []; } exitNodeIdToSiteIds[site.exitNodeId].push(site.siteId); } } exitNodesData = allExitNodes.map((exitNode) => { return { publicKey: exitNode.publicKey, relayPort: config.getRawConfig().gerbil.clients_start_port, endpoint: exitNode.endpoint, siteIds: exitNodeIdToSiteIds[exitNode.exitNodeId] ?? [] }; }); } logger.debug("sendOlmSyncMessage: sending sync message"); await sendToClient( olm.olmId, { type: "olm/sync", data: { sites: siteConfigurations, exitNodes: exitNodesData } }, { compress: canCompress(olm.version, "olm") } ).catch((error) => { logger.warn(`Error sending olm sync message:`, error); }); } ================================================ FILE: server/routers/olm/unarchiveUserOlm.ts ================================================ import { NextFunction, Request, Response } from "express"; import { db } from "@server/db"; import { olms } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import response from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; const paramsSchema = z .object({ userId: z.string(), olmId: z.string() }) .strict(); export async function unarchiveUserOlm( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { olmId } = parsedParams.data; // Check if OLM exists and is archived const [olm] = await db .select() .from(olms) .where(eq(olms.olmId, olmId)) .limit(1); if (!olm) { return next( createHttpError( HttpCode.NOT_FOUND, `OLM with ID ${olmId} not found` ) ); } if (!olm.archived) { return next( createHttpError( HttpCode.BAD_REQUEST, `OLM with ID ${olmId} is not archived` ) ); } // Unarchive the OLM (set archived to false) await db .update(olms) .set({ archived: false }) .where(eq(olms.olmId, olmId)); return response(res, { data: null, success: true, error: false, message: "Device unarchived successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to unarchive device" ) ); } } ================================================ FILE: server/routers/org/checkId.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { orgs } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const getOrgSchema = z.strictObject({ orgId: z.string() }); export async function checkId( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = getOrgSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { orgId } = parsedQuery.data; const org = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (org.length > 0) { return response(res, { data: {}, success: true, error: false, message: "Organization ID already exists", status: HttpCode.OK }); } return response(res, { data: {}, success: true, error: false, message: "Organization ID is available", status: HttpCode.NOT_FOUND }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } ================================================ FILE: server/routers/org/checkOrgUserAccess.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idp, idpOidcConfig } from "@server/db"; import { roles, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { OpenAPITags, registry } from "@server/openApi"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; async function queryUser(orgId: string, userId: string) { const [user] = await db .select({ orgId: userOrgs.orgId, userId: users.userId, email: users.email, username: users.username, name: users.name, type: users.type, roleId: userOrgs.roleId, roleName: roles.name, isOwner: userOrgs.isOwner, isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, autoProvisioned: userOrgs.autoProvisioned, idpId: users.idpId, idpName: idp.name, idpType: idp.type, idpVariant: idpOidcConfig.variant, idpAutoProvision: idp.autoProvision }) .from(userOrgs) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); return user; } export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult; const paramsSchema = z.object({ userId: z.string(), orgId: z.string() }); registry.registerPath({ method: "get", path: "/org/{orgId}/user/{userId}/check", description: "Check a user's access in an organization.", tags: [OpenAPITags.Org, OpenAPITags.User], request: { params: paramsSchema }, responses: {} }); export async function checkOrgUserAccess( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId, userId } = parsedParams.data; if (userId !== req.user?.userId) { return next( createHttpError( HttpCode.FORBIDDEN, "You do not have permission to check this user's access" ) ); } let user; user = await queryUser(orgId, userId); if (!user) { const [fullUser] = await db .select() .from(users) .where(eq(users.email, userId)) .limit(1); if (fullUser) { user = await queryUser(orgId, fullUser.userId); } } if (!user) { return next( createHttpError( HttpCode.NOT_FOUND, `User with ID ${userId} not found in org` ) ); } const policyCheck = await checkOrgAccessPolicy({ orgId, userId, session: req.session }); // if we get here, the user has an org join, we just don't know if they pass the policies return response(res, { data: policyCheck, success: true, error: false, message: "User access checked successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/org/createOrg.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { and, count, eq } from "drizzle-orm"; import { domains, Org, orgDomains, orgs, roleActions, roles, userOrgs, users, actions } 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 config from "@server/lib/config"; import { fromError } from "zod-validation-error"; import { defaultRoleAllowedActions } from "../role"; 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, limitsService, freeLimitSet } from "@server/lib/billing"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; import { doCidrsOverlap } from "@server/lib/ip"; import { generateCA } from "@server/lib/sshCA"; import { encrypt } from "@server/lib/crypto"; const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/; const createOrgSchema = z.strictObject({ 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()]) .union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere .refine((val) => isValidCIDR(val), { message: "Invalid subnet CIDR" }), utilitySubnet: z .union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere .refine((val) => isValidCIDR(val), { message: "Invalid utility subnet CIDR" }) }); registry.registerPath({ method: "put", path: "/org", description: "Create a new organization", tags: [OpenAPITags.Org], request: { body: { content: { "application/json": { schema: createOrgSchema } } } }, responses: {} }); export async function createOrg( req: Request, res: Response, next: NextFunction ): Promise { try { // should this be in a middleware? if (config.getRawConfig().flags?.disable_user_create_org) { if (req.user && !req.user?.serverAdmin) { return next( createHttpError( HttpCode.FORBIDDEN, "Only server admins can create organizations" ) ); } } const parsedBody = createOrgSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { orgId, name, subnet, utilitySubnet } = parsedBody.data; // TODO: for now we are making all of the orgs the same subnet // 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 next( // createHttpError( // HttpCode.CONFLICT, // `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 next( createHttpError( HttpCode.CONFLICT, `Organization with ID ${orgId} already exists` ) ); } if (doCidrsOverlap(subnet, utilitySubnet)) { return next( createHttpError( HttpCode.BAD_REQUEST, `Subnet ${subnet} overlaps with utility subnet ${utilitySubnet}` ) ); } 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 .select() .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({ orgId, name, subnet, utilitySubnet, createdAt: new Date().toISOString(), ...sshCaFields, ...saasBillingFields }) .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", sshSudoMode: "full" }) .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 })) ); } let ownerUserId: string | null = null; if (req.user) { await trx.insert(userOrgs).values({ userId: req.user!.userId, orgId: newOrg[0].orgId, roleId: roleId, isOwner: true }); ownerUserId = req.user!.userId; } else { // if org created by root api key, set the server admin as the owner const [serverAdmin] = await trx .select() .from(users) .where(eq(users.serverAdmin, true)); if (!serverAdmin) { error = "Server admin not found"; trx.rollback(); return; } await trx.insert(userOrgs).values({ userId: serverAdmin.userId, orgId: newOrg[0].orgId, roleId: roleId, isOwner: true }); ownerUserId = serverAdmin.userId; } 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 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) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create org" ) ); } if (error) { return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error)); } if (build === "saas" && isFirstOrg === true) { await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); const customerId = await createCustomer(orgId, req.user?.email); if (customerId) { await usageService.updateCount( orgId, FeatureId.USERS, 1, customerId ); // Only 1 because we are creating the org } } if (numOrgs) { usageService.updateCount( billingOrgIdForNewOrg || orgId, FeatureId.ORGINIZATIONS, numOrgs ); } return response(res, { data: org, success: true, error: false, message: "Organization created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/org/deleteOrg.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; 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 { 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() }); export type DeleteOrgResponse = {}; registry.registerPath({ method: "delete", path: "/org/{orgId}", description: "Delete an organization", tags: [OpenAPITags.Org], request: { params: deleteOrgSchema }, responses: {} }); export async function deleteOrg( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = deleteOrgSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } 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, { data: null, success: true, error: false, message: "Organization deleted successfully", status: HttpCode.OK }); } catch (error) { if (createHttpError.isHttpError(error)) { return next(error); } logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } ================================================ FILE: server/routers/org/getOrg.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { Org, orgs } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const getOrgSchema = z.strictObject({ orgId: z.string() }); export type GetOrgResponse = { org: Org; }; registry.registerPath({ method: "get", path: "/org/{orgId}", description: "Get an organization", tags: [OpenAPITags.Org], request: { params: getOrgSchema }, responses: {} }); export async function getOrg( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getOrgSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const { orgId } = parsedParams.data; const [org] = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (!org) { return next( createHttpError( HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found` ) ); } return response(res, { data: { org }, success: true, error: false, message: "Organization retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/org/getOrgOverview.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { orgs, resources, roles, sites, userOrgs, userResources, users, userSites } from "@server/db"; 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"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; const getOrgParamsSchema = z.strictObject({ orgId: z.string() }); export type GetOrgOverviewResponse = { orgName: string; orgId: string; userRoleName: string; numSites: number; numUsers: number; numResources: number; isAdmin: boolean; isOwner: boolean; }; export async function getOrgOverview( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getOrgParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const { orgId } = parsedParams.data; const org = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (org.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found` ) ); } if (!req.userOrg) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } const allSiteIds = await db .select({ siteId: sites.siteId }) .from(sites) .where(eq(sites.orgId, orgId)); const [{ numSites }] = await db .select({ numSites: count() }) .from(userSites) .where( and( eq(userSites.userId, req.userOrg.userId), inArray( userSites.siteId, allSiteIds.map((site) => site.siteId) ) ) ); const allResourceIds = await db .select({ resourceId: resources.resourceId }) .from(resources) .where(eq(resources.orgId, orgId)); const [{ numResources }] = await db .select({ numResources: count() }) .from(userResources) .where( and( eq(userResources.userId, req.userOrg.userId), inArray( userResources.resourceId, allResourceIds.map((resource) => resource.resourceId) ) ) ); const [{ numUsers }] = await db .select({ numUsers: count() }) .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); const [role] = await db .select() .from(roles) .where(eq(roles.roleId, req.userOrg.roleId)); return response(res, { data: { orgName: org[0].name, orgId: org[0].orgId, userRoleName: role.name, numSites, numUsers, numResources, isAdmin: role.isAdmin || false, isOwner: req.userOrg?.isOwner || false }, success: true, error: false, message: "Organization overview retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/org/index.ts ================================================ export * from "./getOrg"; export * from "./createOrg"; export * from "./deleteOrg"; export * from "./updateOrg"; export * from "./listUserOrgs"; export * from "./checkId"; export * from "./getOrgOverview"; export * from "./listOrgs"; export * from "./pickOrgDefaults"; export * from "./checkOrgUserAccess"; export * from "./resetOrgBandwidth"; ================================================ FILE: server/routers/org/listOrgs.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { Org, orgs, userOrgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { sql, inArray, eq } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const listOrgsSchema = z.object({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); registry.registerPath({ method: "get", path: "/orgs", description: "List all organizations in the system.", tags: [OpenAPITags.Org], request: { query: listOrgsSchema }, responses: {} }); export type ListOrgsResponse = { orgs: Org[]; pagination: { total: number; limit: number; offset: number }; }; export async function listOrgs( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listOrgsSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedQuery.error) ) ); } const { limit, offset } = parsedQuery.data; const organizations = await db .select() .from(orgs) .limit(limit) .offset(offset); const totalCountResult = await db .select({ count: sql`cast(count(*) as integer)` }) .from(orgs); const totalCount = totalCountResult[0].count; return response(res, { data: { orgs: organizations, pagination: { total: totalCount, limit, offset } }, success: true, error: false, message: "Organizations retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } ================================================ FILE: server/routers/org/listUserOrgs.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, roles } from "@server/db"; import { Org, orgs, userOrgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { sql, inArray, eq, and } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const listOrgsParamsSchema = z.object({ userId: z.string() }); const listOrgsSchema = z.object({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); // registry.registerPath({ // method: "get", // path: "/user/{userId}/orgs", // description: "List all organizations for a user.", // tags: [OpenAPITags.Org, OpenAPITags.User], // request: { // query: listOrgsSchema // }, // responses: {} // }); type ResponseOrg = Org & { isOwner?: boolean; isAdmin?: boolean; isPrimaryOrg?: boolean; }; export type ListUserOrgsResponse = { orgs: ResponseOrg[]; pagination: { total: number; limit: number; offset: number }; }; export async function listUserOrgs( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listOrgsSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedQuery.error) ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = listOrgsParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const { userId } = parsedParams.data; const userOrganizations = await db .select({ orgId: userOrgs.orgId, roleId: userOrgs.roleId }) .from(userOrgs) .where(eq(userOrgs.userId, userId)); const userOrgIds = userOrganizations.map((org) => org.orgId); if (!userOrgIds || userOrgIds.length === 0) { return response(res, { data: { orgs: [], pagination: { total: 0, limit, offset } }, success: true, error: false, message: "No organizations found for the user", status: HttpCode.OK }); } const organizations = await db .select() .from(orgs) .where(inArray(orgs.orgId, userOrgIds)) .leftJoin( userOrgs, and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId)) ) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .limit(limit) .offset(offset); const totalCountResult = await db .select({ count: sql`cast(count(*) as integer)` }) .from(orgs) .where(inArray(orgs.orgId, userOrgIds)); const totalCount = totalCountResult[0].count; const responseOrgs = organizations.map((val) => { const res = { ...val.orgs } as ResponseOrg; if (val.userOrgs && val.userOrgs.isOwner) { res.isOwner = val.userOrgs.isOwner; } 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; }); return response(res, { data: { orgs: responseOrgs, pagination: { total: totalCount, limit, offset } }, success: true, error: false, message: "Organizations retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred..." ) ); } } ================================================ FILE: server/routers/org/pickOrgDefaults.ts ================================================ import { Request, Response, NextFunction } from "express"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { getNextAvailableOrgSubnet } from "@server/lib/ip"; import config from "@server/lib/config"; export type PickOrgDefaultsResponse = { subnet: string; utilitySubnet: string; }; export async function pickOrgDefaults( req: Request, res: Response, next: NextFunction ): Promise { try { // TODO: Why would each org have to have its own subnet? // const subnet = await getNextAvailableOrgSubnet(); // Just hard code the subnet for now for everyone const subnet = config.getRawConfig().orgs.subnet_group; const utilitySubnet = config.getRawConfig().orgs.utility_subnet_group; return response(res, { data: { subnet: subnet, utilitySubnet: utilitySubnet }, success: true, error: false, message: "Organization defaults created successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/org/resetOrgBandwidth.ts ================================================ import { NextFunction, Request, Response } from "express"; import { z } from "zod"; import { db, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const resetOrgBandwidthParamsSchema = z.strictObject({ orgId: z.string() }); registry.registerPath({ method: "post", path: "/org/{orgId}/reset-bandwidth", description: "Reset all sites in selected organization bandwidth counters.", tags: [OpenAPITags.Org, OpenAPITags.Site], request: { params: resetOrgBandwidthParamsSchema }, responses: {} }); export async function resetOrgBandwidth( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = resetOrgBandwidthParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const [site] = await db .select({ siteId: sites.siteId }) .from(sites) .where(eq(sites.orgId, orgId)) .limit(1); if (!site) { return next( createHttpError( HttpCode.NOT_FOUND, `No sites found in org ${orgId}` ) ); } await db .update(sites) .set({ megabytesIn: 0, megabytesOut: 0 }) .where(eq(sites.orgId, orgId)); return response(res, { data: {}, success: true, error: false, message: "Sites bandwidth reset successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/org/updateOrg.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { orgs, users } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; import { cache } from "#dynamic/lib/cache"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { getOrgTierData } from "#dynamic/lib/billing"; const updateOrgParamsSchema = z.strictObject({ orgId: z.string() }); const updateOrgBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), requireTwoFactor: z.boolean().optional(), maxSessionLengthHours: z.number().nullable().optional(), passwordExpiryDays: z.number().nullable().optional(), settingsLogRetentionDaysRequest: z .number() .min(build === "saas" ? 0 : -1) .optional(), settingsLogRetentionDaysAccess: z .number() .min(build === "saas" ? 0 : -1) .optional(), settingsLogRetentionDaysAction: z .number() .min(build === "saas" ? 0 : -1) .optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" }); registry.registerPath({ method: "post", path: "/org/{orgId}", description: "Update an organization", tags: [OpenAPITags.Org], request: { params: updateOrgParamsSchema, body: { content: { "application/json": { schema: updateOrgBodySchema } } } }, responses: {} }); export async function updateOrg( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = updateOrgParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = updateOrgBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { orgId } = parsedParams.data; // Check 2FA enforcement feature const has2FAFeature = await isLicensedOrSubscribed( orgId, tierMatrix[TierFeature.TwoFactorEnforcement] ); if (!has2FAFeature) { parsedBody.data.requireTwoFactor = undefined; } // Check session duration policies feature const hasSessionDurationFeature = await isLicensedOrSubscribed( orgId, tierMatrix[TierFeature.SessionDurationPolicies] ); if (!hasSessionDurationFeature) { parsedBody.data.maxSessionLengthHours = undefined; } // Check password expiration policies feature const hasPasswordExpirationFeature = await isLicensedOrSubscribed( orgId, tierMatrix[TierFeature.PasswordExpirationPolicies] ); if (!hasPasswordExpirationFeature) { parsedBody.data.passwordExpiryDays = undefined; } if (build == "saas") { const { tier } = await getOrgTierData(orgId); // Determine max allowed retention days based on tier let maxRetentionDays: number | null = null; if (!tier) { maxRetentionDays = 3; } else if (tier === "tier1") { maxRetentionDays = 7; } else if (tier === "tier2") { maxRetentionDays = 30; } else if (tier === "tier3") { maxRetentionDays = 90; } // For enterprise tier, no check (maxRetentionDays remains null) if (maxRetentionDays !== null) { if ( parsedBody.data.settingsLogRetentionDaysRequest !== undefined && parsedBody.data.settingsLogRetentionDaysRequest > maxRetentionDays ) { return next( createHttpError( HttpCode.FORBIDDEN, `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` ) ); } if ( parsedBody.data.settingsLogRetentionDaysAccess !== undefined && parsedBody.data.settingsLogRetentionDaysAccess > maxRetentionDays ) { return next( createHttpError( HttpCode.FORBIDDEN, `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` ) ); } if ( parsedBody.data.settingsLogRetentionDaysAction !== undefined && parsedBody.data.settingsLogRetentionDaysAction > maxRetentionDays ) { return next( createHttpError( HttpCode.FORBIDDEN, `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` ) ); } } } const updatedOrg = await db .update(orgs) .set({ name: parsedBody.data.name, requireTwoFactor: parsedBody.data.requireTwoFactor, maxSessionLengthHours: parsedBody.data.maxSessionLengthHours, passwordExpiryDays: parsedBody.data.passwordExpiryDays, settingsLogRetentionDaysRequest: parsedBody.data.settingsLogRetentionDaysRequest, settingsLogRetentionDaysAccess: parsedBody.data.settingsLogRetentionDaysAccess, settingsLogRetentionDaysAction: parsedBody.data.settingsLogRetentionDaysAction }) .where(eq(orgs.orgId, orgId)) .returning(); if (updatedOrg.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found` ) ); } // invalidate the cache for all of the orgs retention days await cache.del(`org_${orgId}_retentionDays`); await cache.del(`org_${orgId}_actionDays`); await cache.del(`org_${orgId}_accessDays`); return response(res, { data: updatedOrg[0], success: true, error: false, message: "Organization updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/orgIdp/types.ts ================================================ import { Idp, IdpOidcConfig } from "@server/db"; export type CreateOrgIdpResponse = { idpId: number; redirectUrl: string; }; export type GetOrgIdpResponse = { idp: Idp; idpOidcConfig: IdpOidcConfig | null; redirectUrl: string; }; export type ListOrgIdpsResponse = { idps: { idpId: number; orgId: string; name: string; type: string; variant: string; }[]; pagination: { total: number; limit: number; offset: number; }; }; ================================================ FILE: server/routers/remoteExitNode/types.ts ================================================ import { RemoteExitNode } from "@server/db"; export type CreateRemoteExitNodeResponse = { token: string; remoteExitNodeId: string; secret: string; }; export type PickRemoteExitNodeDefaultsResponse = { remoteExitNodeId: string; secret: string; }; export type QuickStartRemoteExitNodeResponse = { remoteExitNodeId: string; secret: string; }; export type ListRemoteExitNodesResponse = { remoteExitNodes: { remoteExitNodeId: string; dateCreated: string; version: string | null; exitNodeId: number | null; name: string; address: string; endpoint: string; online: boolean; type: string | null; }[]; pagination: { total: number; limit: number; offset: number }; }; export type GetRemoteExitNodeResponse = { remoteExitNodeId: string; dateCreated: string; version: string | null; exitNodeId: number | null; name: string; address: string; endpoint: string; online: boolean; type: string | null; }; ================================================ FILE: server/routers/resource/addEmailToResourceWhitelist.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resources, resourceWhitelist } 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 { and, eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const addEmailToResourceWhitelistBodySchema = z.strictObject({ email: z .email() .or( z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { error: "Invalid email address. Wildcard (*) must be the entire local part." }) ) .transform((v) => v.toLowerCase()) }); const addEmailToResourceWhitelistParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "post", path: "/resource/{resourceId}/whitelist/add", description: "Add a single email to the resource whitelist.", tags: [OpenAPITags.PublicResource], request: { params: addEmailToResourceWhitelistParamsSchema, body: { content: { "application/json": { schema: addEmailToResourceWhitelistBodySchema } } } }, responses: {} }); export async function addEmailToResourceWhitelist( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = addEmailToResourceWhitelistBodySchema.safeParse( req.body ); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { email } = parsedBody.data; const parsedParams = addEmailToResourceWhitelistParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)); if (!resource) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } if (!resource.emailWhitelistEnabled) { return next( createHttpError( HttpCode.BAD_REQUEST, "Email whitelist is not enabled for this resource" ) ); } // Check if email already exists in whitelist const existingEntry = await db .select() .from(resourceWhitelist) .where( and( eq(resourceWhitelist.resourceId, resourceId), eq(resourceWhitelist.email, email) ) ); if (existingEntry.length > 0) { return next( createHttpError( HttpCode.CONFLICT, "Email already exists in whitelist" ) ); } await db.insert(resourceWhitelist).values({ email, resourceId }); return response(res, { data: {}, success: true, error: false, message: "Email added to whitelist successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/addRoleToResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; import { roleResources, roles } 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, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const addRoleToResourceBodySchema = z .object({ roleId: z.number().int().positive() }) .strict(); const addRoleToResourceParamsSchema = z .object({ resourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/resource/{resourceId}/roles/add", description: "Add a single role to a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: addRoleToResourceParamsSchema, body: { content: { "application/json": { schema: addRoleToResourceBodySchema } } } }, responses: {} }); export async function addRoleToResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = addRoleToResourceBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { roleId } = parsedBody.data; const parsedParams = addRoleToResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; // get the resource const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } // verify the role exists and belongs to the same org const [role] = await db .select() .from(roles) .where( and(eq(roles.roleId, roleId), eq(roles.orgId, resource.orgId)) ) .limit(1); if (!role) { return next( createHttpError( HttpCode.NOT_FOUND, "Role not found or does not belong to the same organization" ) ); } // Check if the role is an admin role if (role.isAdmin) { return next( createHttpError( HttpCode.BAD_REQUEST, "Admin role cannot be assigned to resources" ) ); } // Check if role already exists in resource const existingEntry = await db .select() .from(roleResources) .where( and( eq(roleResources.resourceId, resourceId), eq(roleResources.roleId, roleId) ) ); if (existingEntry.length > 0) { return next( createHttpError( HttpCode.CONFLICT, "Role already assigned to resource" ) ); } await db.insert(roleResources).values({ roleId, resourceId }); return response(res, { data: {}, success: true, error: false, message: "Role added to resource successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/addUserToResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; import { userResources } 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, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const addUserToResourceBodySchema = z .object({ userId: z.string() }) .strict(); const addUserToResourceParamsSchema = z .object({ resourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/resource/{resourceId}/users/add", description: "Add a single user to a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: addUserToResourceParamsSchema, body: { content: { "application/json": { schema: addUserToResourceBodySchema } } } }, responses: {} }); export async function addUserToResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = addUserToResourceBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { userId } = parsedBody.data; const parsedParams = addUserToResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; // get the resource const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } // Check if user already exists in resource const existingEntry = await db .select() .from(userResources) .where( and( eq(userResources.resourceId, resourceId), eq(userResources.userId, userId) ) ); if (existingEntry.length > 0) { return next( createHttpError( HttpCode.CONFLICT, "User already assigned to resource" ) ); } await db.insert(userResources).values({ userId, resourceId }); return response(res, { data: {}, success: true, error: false, message: "User added to resource successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/authWithAccessToken.ts ================================================ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; import { Resource, resources } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } 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 { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import config from "@server/lib/config"; import stoi from "@server/lib/stoi"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath"; const authWithAccessTokenBodySchema = z.strictObject({ accessToken: z.string(), accessTokenId: z.string().optional() }); const authWithAccessTokenParamsSchema = z.strictObject({ resourceId: z .string() .optional() .transform(stoi) .pipe(z.int().positive().optional()) }); export type AuthWithAccessTokenResponse = { session?: string; redirectUrl?: string | null; }; export async function authWithAccessToken( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = authWithAccessTokenBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const parsedParams = authWithAccessTokenParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const { accessToken, accessTokenId } = parsedBody.data; try { let valid; let tokenItem; let error; let resource: Resource | undefined; if (accessTokenId) { if (!resourceId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Resource ID is required" ) ); } const [foundResource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!foundResource) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } const res = await verifyResourceAccessToken({ accessTokenId, accessToken }); valid = res.valid; tokenItem = res.tokenItem; error = res.error; resource = foundResource; } else { const res = await verifyResourceAccessToken({ accessToken }); valid = res.valid; tokenItem = res.tokenItem; error = res.error; resource = res.resource; } if (!tokenItem || !resource) { return next( createHttpError( HttpCode.UNAUTHORIZED, "Access token does not exist for resource" ) ); } if (!valid) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } logAccessAudit({ orgId: resource.orgId, resourceId: resource.resourceId, action: false, type: "accessToken", userAgent: req.headers["user-agent"], requestIp: req.ip }); return next( createHttpError( HttpCode.UNAUTHORIZED, error || "Invalid access token" ) ); } const token = generateSessionToken(); await createResourceSession({ resourceId: resource.resourceId, token, accessTokenId: tokenItem.accessTokenId, isRequestToken: true, expiresAt: Date.now() + 1000 * 30, // 30 seconds sessionLength: 1000 * 30, doNotExtend: true }); logAccessAudit({ orgId: resource.orgId, resourceId: resource.resourceId, action: true, type: "accessToken", userAgent: req.headers["user-agent"], requestIp: req.ip }); let redirectUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; const postAuthPath = normalizePostAuthPath(resource.postAuthPath); if (postAuthPath) { redirectUrl = redirectUrl + postAuthPath; } return response(res, { data: { session: token, redirectUrl }, success: true, error: false, message: "Authenticated with resource successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to authenticate with resource" ) ); } } ================================================ FILE: server/routers/resource/authWithPassword.ts ================================================ import { verify } from "@node-rs/argon2"; import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; import { orgs, resourcePassword, resources } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } 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 { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import config from "@server/lib/config"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; export const authWithPasswordBodySchema = z.strictObject({ password: z.string() }); export const authWithPasswordParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); export type AuthWithPasswordResponse = { session?: string; }; export async function authWithPassword( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = authWithPasswordBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const parsedParams = authWithPasswordParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const { password } = parsedBody.data; try { const [result] = await db .select() .from(resources) .leftJoin( resourcePassword, eq(resourcePassword.resourceId, resources.resourceId) ) .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) .where(eq(resources.resourceId, resourceId)) .limit(1); const resource = result?.resources; const org = result?.orgs; const definedPassword = result?.resourcePassword; if (!org) { return next( createHttpError(HttpCode.BAD_REQUEST, "Org does not exist") ); } if (!resource) { return next( createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") ); } if (!definedPassword) { return next( createHttpError( HttpCode.UNAUTHORIZED, createHttpError( HttpCode.BAD_REQUEST, "Resource has no password protection" ) ) ); } const validPassword = await verifyPassword( password, definedPassword.passwordHash ); if (!validPassword) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } logAccessAudit({ orgId: org.orgId, resourceId: resource.resourceId, action: false, type: "password", userAgent: req.headers["user-agent"], requestIp: req.ip }); return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password") ); } const token = generateSessionToken(); await createResourceSession({ resourceId, token, passwordId: definedPassword.passwordId, isRequestToken: true, expiresAt: Date.now() + 1000 * 30, // 30 seconds sessionLength: 1000 * 30, doNotExtend: true }); logAccessAudit({ orgId: org.orgId, resourceId: resource.resourceId, action: true, type: "password", userAgent: req.headers["user-agent"], requestIp: req.ip }); return response(res, { data: { session: token }, success: true, error: false, message: "Authenticated with resource successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to authenticate with resource" ) ); } } ================================================ FILE: server/routers/resource/authWithPincode.ts ================================================ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; import { orgs, resourcePincode, resources } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } 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 { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import config from "@server/lib/config"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; export const authWithPincodeBodySchema = z.strictObject({ pincode: z.string() }); export const authWithPincodeParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); export type AuthWithPincodeResponse = { session?: string; }; export async function authWithPincode( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = authWithPincodeBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const parsedParams = authWithPincodeParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const { pincode } = parsedBody.data; try { const [result] = await db .select() .from(resources) .leftJoin( resourcePincode, eq(resourcePincode.resourceId, resources.resourceId) ) .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) .where(eq(resources.resourceId, resourceId)) .limit(1); const resource = result?.resources; const org = result?.orgs; const definedPincode = result?.resourcePincode; if (!org) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Org does not exist" ) ); } if (!resource) { return next( createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") ); } if (!definedPincode) { return next( createHttpError( HttpCode.UNAUTHORIZED, "Resource has no pincode protection" ) ); } const validPincode = await verifyPassword( pincode, definedPincode.pincodeHash ); if (!validPincode) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } logAccessAudit({ orgId: org.orgId, resourceId: resource.resourceId, action: false, type: "pincode", userAgent: req.headers["user-agent"], requestIp: req.ip }); return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN") ); } const token = generateSessionToken(); await createResourceSession({ resourceId, token, pincodeId: definedPincode.pincodeId, isRequestToken: true, expiresAt: Date.now() + 1000 * 30, // 30 seconds sessionLength: 1000 * 30, doNotExtend: true }); logAccessAudit({ orgId: org.orgId, resourceId: resource.resourceId, action: true, type: "pincode", userAgent: req.headers["user-agent"], requestIp: req.ip }); return response(res, { data: { session: token }, success: true, error: false, message: "Authenticated with resource successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to authenticate with resource" ) ); } } ================================================ FILE: server/routers/resource/authWithWhitelist.ts ================================================ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; import { orgs, resourceOtp, resources, resourceWhitelist } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, and } 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 { createResourceSession } from "@server/auth/sessions/resource"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import logger from "@server/logger"; import config from "@server/lib/config"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; const authWithWhitelistBodySchema = z.strictObject({ email: z.email().toLowerCase(), otp: z.string().optional() }); const authWithWhitelistParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); export type AuthWithWhitelistResponse = { otpSent?: boolean; session?: string; }; export async function authWithWhitelist( req: Request, res: Response, next: NextFunction ): Promise { const parsedBody = authWithWhitelistBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const parsedParams = authWithWhitelistParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const { email, otp } = parsedBody.data; try { const [result] = await db .select() .from(resourceWhitelist) .where( and( eq(resourceWhitelist.resourceId, resourceId), eq(resourceWhitelist.email, email) ) ) .leftJoin( resources, eq(resources.resourceId, resourceWhitelist.resourceId) ) .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) .limit(1); let resource = result?.resources; let org = result?.orgs; let whitelistedEmail = result?.resourceWhitelist; if (!whitelistedEmail) { // if email is not found, check for wildcard email const wildcard = "*@" + email.split("@")[1]; logger.debug("Checking for wildcard email: " + wildcard); const [result] = await db .select() .from(resourceWhitelist) .where( and( eq(resourceWhitelist.resourceId, resourceId), eq(resourceWhitelist.email, wildcard) ) ) .leftJoin( resources, eq(resources.resourceId, resourceWhitelist.resourceId) ) .leftJoin(orgs, eq(orgs.orgId, resources.orgId)) .limit(1); resource = result?.resources; org = result?.orgs; whitelistedEmail = result?.resourceWhitelist; // if wildcard is still not found, return unauthorized if (!whitelistedEmail) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Email is not whitelisted. Email: ${email}. IP: ${req.ip}.` ); } if (org && resource) { logAccessAudit({ orgId: org.orgId, resourceId: resource.resourceId, action: false, type: "whitelistedEmail", metadata: { email }, userAgent: req.headers["user-agent"], requestIp: req.ip }); } return next( createHttpError( HttpCode.UNAUTHORIZED, createHttpError( HttpCode.BAD_REQUEST, "Email is not whitelisted" ) ) ); } } if (!org) { return next( createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") ); } if (!resource) { return next( createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist") ); } if (otp && email) { const isValidCode = await isValidOtp( email, resource.resourceId, otp ); if (!isValidCode) { if (config.getRawConfig().app.log_failed_attempts) { logger.info( `Resource email otp incorrect. Resource ID: ${resource.resourceId}. Email: ${email}. IP: ${req.ip}.` ); } return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP") ); } await db .delete(resourceOtp) .where( and( eq(resourceOtp.email, email), eq(resourceOtp.resourceId, resource.resourceId) ) ); } else if (email) { try { await sendResourceOtpEmail( email, resource.resourceId, resource.name, org.name ); return response(res, { data: { otpSent: true }, success: true, error: false, message: "Sent one-time otp to email address", status: HttpCode.ACCEPTED }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to send one-time otp. Make sure the email address is correct and try again." ) ); } } else { return next( createHttpError( HttpCode.BAD_REQUEST, "Email is required for whitelist authentication" ) ); } const token = generateSessionToken(); await createResourceSession({ resourceId, token, whitelistId: whitelistedEmail.whitelistId, isRequestToken: true, expiresAt: Date.now() + 1000 * 30, // 30 seconds sessionLength: 1000 * 30, doNotExtend: true }); logAccessAudit({ orgId: org.orgId, resourceId: resource.resourceId, action: true, metadata: { email }, type: "whitelistedEmail", userAgent: req.headers["user-agent"], requestIp: req.ip }); return response(res, { data: { session: token }, success: true, error: false, message: "Authenticated with resource successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to authenticate with resource" ) ); } } ================================================ FILE: server/routers/resource/createResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, loginPage } from "@server/db"; import { domains, orgDomains, orgs, Resource, resources, roleResources, roles, userResources } 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 { subdomainSchema } from "@server/lib/schemas"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { getUniqueResourceName } from "@server/db/names"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; const createResourceParamsSchema = z.strictObject({ orgId: z.string() }); const createHttpResourceSchema = z .strictObject({ name: z.string().min(1).max(255), subdomain: z.string().nullable().optional(), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), domainId: z.string(), stickySession: z.boolean().optional(), postAuthPath: z.string().nullable().optional() }) .refine( (data) => { if (data.subdomain) { return subdomainSchema.safeParse(data.subdomain).success; } return true; }, { error: "Invalid subdomain" } ); const createRawResourceSchema = z .strictObject({ name: z.string().min(1).max(255), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), proxyPort: z.int().min(1).max(65535) // enableProxy: z.boolean().default(true) // always true now }) .refine( (data) => { if (!config.getRawConfig().flags?.allow_raw_resources) { if (data.proxyPort !== undefined) { return false; } } return true; }, { error: "Raw resources are not allowed" } ); export type CreateResourceResponse = Resource; registry.registerPath({ method: "put", path: "/org/{orgId}/resource", description: "Create a resource.", tags: [OpenAPITags.PublicResource], request: { params: createResourceParamsSchema, body: { content: { "application/json": { schema: createHttpResourceSchema.or(createRawResourceSchema) } } } }, responses: {} }); export async function createResource( req: Request, res: Response, next: NextFunction ): Promise { try { // Validate request params const parsedParams = createResourceParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); } // get the org const org = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (org.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found` ) ); } if (typeof req.body.http !== "boolean") { return next( createHttpError(HttpCode.BAD_REQUEST, "http field is required") ); } const { http } = req.body; if (http) { return await createHttpResource({ req, res, next }, { orgId }); } else { if ( !config.getRawConfig().flags?.allow_raw_resources && build == "oss" ) { return next( createHttpError( HttpCode.BAD_REQUEST, "Raw resources are not allowed" ) ); } return await createRawResource({ req, res, next }, { orgId }); } } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } async function createHttpResource( route: { req: Request; res: Response; next: NextFunction; }, meta: { orgId: string; } ) { const { req, res, next } = route; const { orgId } = meta; const parsedBody = createHttpResourceSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { name, domainId, postAuthPath } = parsedBody.data; const subdomain = parsedBody.data.subdomain; const stickySession = parsedBody.data.stickySession; // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, orgId, subdomain ); if (!domainResult.success) { return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error)); } const { fullDomain, subdomain: finalSubdomain } = domainResult; logger.debug(`Full domain: ${fullDomain}`); // make sure the full domain is unique const existingResource = await db .select() .from(resources) .where(eq(resources.fullDomain, fullDomain)); if (existingResource.length > 0) { return next( createHttpError( HttpCode.CONFLICT, "Resource with that domain already exists" ) ); } // Prevent creating resource with same domain as dashboard const dashboardUrl = config.getRawConfig().app.dashboard_url; if (dashboardUrl) { const dashboardHost = new URL(dashboardUrl).hostname; if (fullDomain === dashboardHost) { return next( createHttpError( HttpCode.CONFLICT, "Resource domain cannot be the same as the dashboard domain" ) ); } } if (build != "oss") { const existingLoginPages = await db .select() .from(loginPage) .where(eq(loginPage.fullDomain, fullDomain)); if (existingLoginPages.length > 0) { return next( createHttpError( HttpCode.CONFLICT, "Login page with that domain already exists" ) ); } } let resource: Resource | undefined; const niceId = await getUniqueResourceName(orgId); await db.transaction(async (trx) => { const newResource = await trx .insert(resources) .values({ niceId, fullDomain, domainId, orgId, name, subdomain: finalSubdomain, http: true, protocol: "tcp", ssl: true, stickySession: stickySession, postAuthPath: postAuthPath }) .returning(); const adminRole = await db .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .limit(1); if (adminRole.length === 0) { return next( createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) ); } await trx.insert(roleResources).values({ roleId: adminRole[0].roleId, resourceId: newResource[0].resourceId }); if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, resourceId: newResource[0].resourceId }); } resource = newResource[0]; }); if (!resource) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create resource" ) ); } if (build != "oss") { await createCertificate(domainId, fullDomain, db); } return response(res, { data: resource, success: true, error: false, message: "Http resource created successfully", status: HttpCode.CREATED }); } async function createRawResource( route: { req: Request; res: Response; next: NextFunction; }, meta: { orgId: string; } ) { const { req, res, next } = route; const { orgId } = meta; const parsedBody = createRawResourceSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { name, http, protocol, proxyPort } = parsedBody.data; let resource: Resource | undefined; const niceId = await getUniqueResourceName(orgId); await db.transaction(async (trx) => { const newResource = await trx .insert(resources) .values({ niceId, orgId, name, http, protocol, proxyPort // enableProxy }) .returning(); const adminRole = await db .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .limit(1); if (adminRole.length === 0) { return next( createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) ); } await trx.insert(roleResources).values({ roleId: adminRole[0].roleId, resourceId: newResource[0].resourceId }); if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, resourceId: newResource[0].resourceId }); } resource = newResource[0]; }); if (!resource) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create resource" ) ); } return response(res, { data: resource, success: true, error: false, message: "Non-http resource created successfully", status: HttpCode.CREATED }); } ================================================ FILE: server/routers/resource/createResourceRule.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resourceRules, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; const createResourceRuleSchema = z.strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]), match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]), value: z.string().min(1), priority: z.int(), enabled: z.boolean().optional() }); const createResourceRuleParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "put", path: "/resource/{resourceId}/rule", description: "Create a resource rule.", tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: createResourceRuleParamsSchema, body: { content: { "application/json": { schema: createResourceRuleSchema } } } }, responses: {} }); export async function createResourceRule( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = createResourceRuleSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { action, match, value, priority, enabled } = parsedBody.data; const parsedParams = createResourceRuleParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; // Verify that the referenced resource exists const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } if (!resource.http) { return next( createHttpError( HttpCode.BAD_REQUEST, "Cannot create rule for non-http resource" ) ); } if (match === "CIDR") { if (!isValidCIDR(value)) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid CIDR provided" ) ); } } else if (match === "IP") { if (!isValidIP(value)) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided") ); } } else if (match === "PATH") { if (!isValidUrlGlobPattern(value)) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid URL glob pattern provided" ) ); } } // Create the new resource rule const [newRule] = await db .insert(resourceRules) .values({ resourceId, action, match, value, priority, enabled }) .returning(); return response(res, { data: newRule, success: true, error: false, message: "Resource rule created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/deleteResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { newts, resources, sites, targets } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { removeTargets } from "../newt/targets"; import { getAllowedIps } from "../target/helpers"; import { OpenAPITags, registry } from "@server/openApi"; // Define Zod schema for request parameters validation const deleteResourceSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "delete", path: "/resource/{resourceId}", description: "Delete a resource.", tags: [OpenAPITags.PublicResource], request: { params: deleteResourceSchema }, responses: {} }); export async function deleteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = deleteResourceSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const targetsToBeRemoved = await db .select() .from(targets) .where(eq(targets.resourceId, resourceId)); const [deletedResource] = await db .delete(resources) .where(eq(resources.resourceId, resourceId)) .returning(); if (!deletedResource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } // const [site] = await db // .select() // .from(sites) // .where(eq(sites.siteId, deletedResource.siteId!)) // .limit(1); // // if (!site) { // return next( // createHttpError( // HttpCode.NOT_FOUND, // `Site with ID ${deletedResource.siteId} not found` // ) // ); // } // // if (site.pubKey) { // if (site.type == "wireguard") { // await addPeer(site.exitNodeId!, { // publicKey: site.pubKey, // allowedIps: await getAllowedIps(site.siteId) // }); // } else if (site.type == "newt") { // // get the newt on the site by querying the newt table for siteId // const [newt] = await db // .select() // .from(newts) // .where(eq(newts.siteId, site.siteId)) // .limit(1); // // removeTargets( // newt.newtId, // targetsToBeRemoved, // deletedResource.protocol, // deletedResource.proxyPort // ); // } // } // return response(res, { data: null, success: true, error: false, message: "Resource deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/deleteResourceRule.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resourceRules, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const deleteResourceRuleSchema = z.strictObject({ ruleId: z.string().transform(Number).pipe(z.int().positive()), resourceId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "delete", path: "/resource/{resourceId}/rule/{ruleId}", description: "Delete a resource rule.", tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: deleteResourceRuleSchema }, responses: {} }); export async function deleteResourceRule( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = deleteResourceRuleSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { ruleId } = parsedParams.data; // Delete the rule and return the deleted record const [deletedRule] = await db .delete(resourceRules) .where(eq(resourceRules.ruleId, ruleId)) .returning(); if (!deletedRule) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource rule with ID ${ruleId} not found` ) ); } return response(res, { data: null, success: true, error: false, message: "Resource rule deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/getExchangeToken.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resources } from "@server/db"; import { eq } from "drizzle-orm"; import { createResourceSession } from "@server/auth/sessions/resource"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { generateSessionToken } from "@server/auth/sessions/app"; import config from "@server/lib/config"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { response } from "@server/lib/response"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; const getExchangeTokenParams = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); export type GetExchangeTokenResponse = { requestToken: string; }; export async function getExchangeToken( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getExchangeTokenParams.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } const ssoSession = req.cookies[config.getRawConfig().server.session_cookie_name]; if (!ssoSession) { logger.debug(ssoSession); return next( createHttpError( HttpCode.UNAUTHORIZED, "Missing SSO session cookie" ) ); } // check org policy here const hasAccess = await checkOrgAccessPolicy({ orgId: resource.orgId, userId: req.user!.userId, session: req.session }); if (!hasAccess.allowed || hasAccess.error) { return next( createHttpError( HttpCode.FORBIDDEN, "Failed organization access policy check: " + (hasAccess.error || "Unknown error") ) ); } const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(ssoSession)) ); const token = generateSessionToken(); await createResourceSession({ resourceId, token, userSessionId: sessionId, isRequestToken: true, expiresAt: Date.now() + 1000 * 30, // 30 seconds sessionLength: 1000 * 30, doNotExtend: true }); if (req.user) { logAccessAudit({ orgId: resource.orgId, resourceId: resourceId, user: { username: req.user.username, userId: req.user.userId }, action: true, type: "login", userAgent: req.headers["user-agent"], requestIp: req.ip }); } logger.debug("Request token created successfully"); return response(res, { data: { requestToken: token }, success: true, error: false, message: "Request token created successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/getResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { Resource, resources, sites } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { OpenAPITags, registry } from "@server/openApi"; const getResourceSchema = z.strictObject({ resourceId: z .string() .optional() .transform(stoi) .pipe(z.int().positive().optional()) .optional(), niceId: z.string().optional(), orgId: z.string().optional() }); async function query(resourceId?: number, niceId?: string, orgId?: string) { if (resourceId) { const [res] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); return res; } else if (niceId && orgId) { const [res] = await db .select() .from(resources) .where( and(eq(resources.niceId, niceId), eq(resources.orgId, orgId)) ) .limit(1); return res; } } export type GetResourceResponse = Omit< NonNullable>>, "headers" > & { headers: { name: string; value: string }[] | null; }; registry.registerPath({ method: "get", path: "/org/{orgId}/resource/{niceId}", description: "Get a resource by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", tags: [OpenAPITags.PublicResource], request: { params: z.object({ orgId: z.string(), niceId: z.string() }) }, responses: {} }); registry.registerPath({ method: "get", path: "/resource/{resourceId}", description: "Get a resource by resourceId.", tags: [OpenAPITags.PublicResource], request: { params: z.object({ resourceId: z.number() }) }, responses: {} }); export async function getResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getResourceSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId, niceId, orgId } = parsedParams.data; const resource = await query(resourceId, niceId, orgId); if (!resource) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } return response(res, { data: { ...resource, headers: resource.headers ? JSON.parse(resource.headers) : resource.headers }, success: true, error: false, message: "Resource retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/getResourceAuthInfo.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, resourcePassword, resourcePincode, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { build } from "@server/build"; const getResourceAuthInfoSchema = z.strictObject({ resourceGuid: z.string() }); export type GetResourceAuthInfoResponse = { resourceId: number; resourceGuid: string; resourceName: string; niceId: string; password: boolean; pincode: boolean; headerAuth: boolean; headerAuthExtendedCompatibility: boolean; sso: boolean; blockAccess: boolean; url: string; whitelist: boolean; skipToIdpId: number | null; orgId: string; postAuthPath: string | null; }; export async function getResourceAuthInfo( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getResourceAuthInfoSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceGuid } = parsedParams.data; const isGuidInteger = /^\d+$/.test(resourceGuid); const [result] = isGuidInteger && build === "saas" ? await db .select() .from(resources) .leftJoin( resourcePincode, eq(resourcePincode.resourceId, resources.resourceId) ) .leftJoin( resourcePassword, eq(resourcePassword.resourceId, resources.resourceId) ) .leftJoin( resourceHeaderAuth, eq( resourceHeaderAuth.resourceId, resources.resourceId ) ) .leftJoin( resourceHeaderAuthExtendedCompatibility, eq( resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId ) ) .where(eq(resources.resourceId, Number(resourceGuid))) .limit(1) : await db .select() .from(resources) .leftJoin( resourcePincode, eq(resourcePincode.resourceId, resources.resourceId) ) .leftJoin( resourcePassword, eq(resourcePassword.resourceId, resources.resourceId) ) .leftJoin( resourceHeaderAuth, eq( resourceHeaderAuth.resourceId, resources.resourceId ) ) .leftJoin( resourceHeaderAuthExtendedCompatibility, eq( resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId ) ) .where(eq(resources.resourceGuid, resourceGuid)) .limit(1); const resource = result?.resources; if (!resource) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } const pincode = result?.resourcePincode; const password = result?.resourcePassword; const headerAuth = result?.resourceHeaderAuth; const headerAuthExtendedCompatibility = result?.resourceHeaderAuthExtendedCompatibility; const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; return response(res, { data: { niceId: resource.niceId, resourceGuid: resource.resourceGuid, resourceId: resource.resourceId, resourceName: resource.name, password: password !== null, pincode: pincode !== null, headerAuth: headerAuth !== null, headerAuthExtendedCompatibility: headerAuthExtendedCompatibility !== null, sso: resource.sso, blockAccess: resource.blockAccess, url, whitelist: resource.emailWhitelistEnabled, skipToIdpId: resource.skipToIdpId, orgId: resource.orgId, postAuthPath: resource.postAuthPath ?? null }, success: true, error: false, message: "Resource auth info retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/getResourceWhitelist.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resourceWhitelist, users } from "@server/db"; // Assuming these are the correct tables import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const getResourceWhitelistSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); async function queryWhitelist(resourceId: number) { return await db .select({ email: resourceWhitelist.email }) .from(resourceWhitelist) .where(eq(resourceWhitelist.resourceId, resourceId)); } export type GetResourceWhitelistResponse = { whitelist: NonNullable>>; }; registry.registerPath({ method: "get", path: "/resource/{resourceId}/whitelist", description: "Get the whitelist of emails for a specific resource.", tags: [OpenAPITags.PublicResource], request: { params: getResourceWhitelistSchema }, responses: {} }); export async function getResourceWhitelist( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getResourceWhitelistSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const whitelist = await queryWhitelist(resourceId); return response(res, { data: { whitelist }, success: true, error: false, message: "Resource whitelist retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/getUserResources.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { and, eq, or, inArray } from "drizzle-orm"; import { resources, userResources, roleResources, userOrgs, resourcePassword, resourcePincode, resourceWhitelist, siteResources, userSiteResources, roleSiteResources } from "@server/db"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib/response"; export async function getUserResources( req: Request, res: Response, next: NextFunction ): Promise { try { const { orgId } = req.params; const userId = req.user?.userId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") ); } // First get the user's role in the organization const userOrgResult = await db .select({ roleId: userOrgs.roleId }) .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); if (userOrgResult.length === 0) { return next( createHttpError(HttpCode.FORBIDDEN, "User not in organization") ); } const userRoleId = userOrgResult[0].roleId; // Get resources accessible through direct assignment or role assignment const directResourcesQuery = db .select({ resourceId: userResources.resourceId }) .from(userResources) .where(eq(userResources.userId, userId)); const roleResourcesQuery = db .select({ resourceId: roleResources.resourceId }) .from(roleResources) .where(eq(roleResources.roleId, userRoleId)); 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, directSiteResourcesQuery, roleSiteResourcesQuery ]); // Combine all accessible resource IDs const accessibleResourceIds = [ ...directResources.map((r) => r.resourceId), ...roleResourceResults.map((r) => r.resourceId) ]; // Combine all accessible site resource IDs const accessibleSiteResourceIds = [ ...directSiteResourceResults.map((r) => r.siteResourceId), ...roleSiteResourceResults.map((r) => r.siteResourceId) ]; // Get resource details for accessible resources 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, fullDomain: resources.fullDomain, ssl: resources.ssl, enabled: resources.enabled, sso: resources.sso, protocol: resources.protocol, emailWhitelistEnabled: resources.emailWhitelistEnabled }) .from(resources) .where( and( inArray(resources.resourceId, accessibleResourceIds), eq(resources.orgId, orgId), 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( resourcesData.map(async (resource) => { const [passwordCheck, pincodeCheck, whitelistCheck] = await Promise.all([ db .select() .from(resourcePassword) .where( eq( resourcePassword.resourceId, resource.resourceId ) ) .limit(1), db .select() .from(resourcePincode) .where( eq( resourcePincode.resourceId, resource.resourceId ) ) .limit(1), db .select() .from(resourceWhitelist) .where( eq( resourceWhitelist.resourceId, resource.resourceId ) ) .limit(1) ]); const hasPassword = passwordCheck.length > 0; const hasPincode = pincodeCheck.length > 0; const hasWhitelist = whitelistCheck.length > 0 || resource.emailWhitelistEnabled; return { resourceId: resource.resourceId, name: resource.name, domain: `${resource.ssl ? "https://" : "http://"}${resource.fullDomain}`, enabled: resource.enabled, protected: !!( resource.sso || hasPassword || hasPincode || hasWhitelist ), protocol: resource.protocol, sso: resource.sso, password: hasPassword, pincode: hasPincode, whitelist: hasWhitelist }; }) ); // 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, siteResources: siteResourcesFormatted }, success: true, error: false, message: "User resources retrieved successfully", status: HttpCode.OK }); } catch (error) { console.error("Error fetching user resources:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Internal server error" ) ); } } export type GetUserResourcesResponse = { success: boolean; data: { resources: Array<{ resourceId: number; name: string; domain: string; enabled: boolean; 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'; }>; }; }; ================================================ FILE: server/routers/resource/index.ts ================================================ export * from "./getResource"; export * from "./createResource"; export * from "./deleteResource"; export * from "./updateResource"; export * from "./listResources"; export * from "./listResourceRoles"; export * from "./setResourceUsers"; export * from "./setResourceRoles"; export * from "./listResourceUsers"; export * from "./setResourcePassword"; export * from "./authWithPassword"; export * from "./getResourceAuthInfo"; export * from "./setResourcePincode"; export * from "./authWithPincode"; export * from "./setResourceWhitelist"; export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; export * from "./getExchangeToken"; export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; export * from "./updateResourceRule"; export * from "./getUserResources"; export * from "./setResourceHeaderAuth"; export * from "./addEmailToResourceWhitelist"; export * from "./removeEmailFromResourceWhitelist"; export * from "./addRoleToResource"; export * from "./removeRoleFromResource"; export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; export * from "./removeEmailFromResourceWhitelist"; ================================================ FILE: server/routers/resource/listAllResourceNames.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const listResourcesParamsSchema = z.strictObject({ orgId: z.string() }); function queryResourceNames(orgId: string) { return db .select({ resourceId: resources.resourceId, name: resources.name }) .from(resources) .where(eq(resources.orgId, orgId)); } export type ListResourceNamesResponse = Awaited< ReturnType >; registry.registerPath({ method: "get", path: "/org/{orgId}/resources-names", description: "List all resource names for an organization.", tags: [OpenAPITags.PublicResource], request: { params: z.object({ orgId: z.string() }) }, responses: {} }); export async function listAllResourceNames( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listResourcesParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const orgId = parsedParams.data.orgId; const data = await queryResourceNames(orgId); return response(res, { data, success: true, error: false, message: "Resource Names retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/listResourceRoles.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roleResources, roles } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const listResourceRolesSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); async function query(resourceId: number) { return await db .select({ roleId: roles.roleId, name: roles.name, description: roles.description, isAdmin: roles.isAdmin }) .from(roleResources) .innerJoin(roles, eq(roleResources.roleId, roles.roleId)) .where(eq(roleResources.resourceId, resourceId)); } export type ListResourceRolesResponse = { roles: NonNullable>>; }; registry.registerPath({ method: "get", path: "/resource/{resourceId}/roles", description: "List all roles for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: listResourceRolesSchema }, responses: {} }); export async function listResourceRoles( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listResourceRolesSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const resourceRolesList = await query(resourceId); return response(res, { data: { roles: resourceRolesList }, success: true, error: false, message: "Resource roles retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/listResourceRules.ts ================================================ import { db } from "@server/db"; import { resourceRules, resources } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, 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 logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; const listResourceRulesParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); const listResourceRulesSchema = z.object({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); function queryResourceRules(resourceId: number) { const baseQuery = db .select({ ruleId: resourceRules.ruleId, resourceId: resourceRules.resourceId, action: resourceRules.action, match: resourceRules.match, value: resourceRules.value, priority: resourceRules.priority, enabled: resourceRules.enabled }) .from(resourceRules) .leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId)) .where(eq(resourceRules.resourceId, resourceId)); return baseQuery; } export type ListResourceRulesResponse = { rules: Awaited>; pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ method: "get", path: "/resource/{resourceId}/rules", description: "List rules for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: listResourceRulesParamsSchema, query: listResourceRulesSchema }, responses: {} }); export async function listResourceRules( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listResourceRulesSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = listResourceRulesParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const { resourceId } = parsedParams.data; // Verify the resource exists const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } const baseQuery = queryResourceRules(resourceId); const countQuery = db .select({ count: sql`cast(count(*) as integer)` }) .from(resourceRules) .where(eq(resourceRules.resourceId, resourceId)); let rulesList = await baseQuery.limit(limit).offset(offset); const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; // sort rules list by the priority in ascending order rulesList = rulesList.sort((a, b) => a.priority - b.priority); return response(res, { data: { rules: rulesList, pagination: { total: totalCount, limit, offset } }, success: true, error: false, message: "Resource rules retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/listResourceUsers.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { idp, userResources, users } from "@server/db"; // Assuming these are the correct tables import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const listResourceUsersSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); async function queryUsers(resourceId: number) { return await db .select({ userId: userResources.userId, username: users.username, type: users.type, idpName: idp.name, idpId: users.idpId, email: users.email }) .from(userResources) .innerJoin(users, eq(userResources.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .where(eq(userResources.resourceId, resourceId)); } export type ListResourceUsersResponse = { users: NonNullable>>; }; registry.registerPath({ method: "get", path: "/resource/{resourceId}/users", description: "List all users for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: listResourceUsersSchema }, responses: {} }); export async function listResourceUsers( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listResourceUsersSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const resourceUsersList = await queryUsers(resourceId); return response(res, { data: { users: resourceUsersList }, success: true, error: false, message: "Resource users retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/listResources.ts ================================================ import { db, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, resourcePassword, resourcePincode, resources, roleResources, targetHealthCheck, targets, userResources } 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 type { PaginatedResponse } from "@server/types/Pagination"; import { and, asc, count, desc, 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({ pageSize: z.coerce .number() // 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() // 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(["name"]) .optional() .catch(undefined) .openapi({ type: "string", enum: ["name"], description: "Field to sort by" }), order: z .enum(["asc", "desc"]) .optional() .default("asc") .catch("asc") .openapi({ type: "string", enum: ["asc", "desc"], default: "asc", description: "Sort order" }), enabled: z .enum(["true", "false"]) .transform((v) => v === "true") .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." }) }); // grouped by resource with targets[]) export type ResourceWithTargets = { resourceId: number; 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; niceId: string; headerAuthId: number | null; targets: Array<{ targetId: number; ip: string; port: number; enabled: boolean; healthStatus: "healthy" | "unhealthy" | "unknown" | null; }>; }; // Aggregate filters const total_targets = count(targets.targetId); const healthy_targets = sql`SUM( CASE WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1 ELSE 0 END ) `; const unknown_targets = sql`SUM( CASE WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1 ELSE 0 END ) `; const unhealthy_targets = sql`SUM( CASE WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1 ELSE 0 END ) `; function queryResourcesBase() { return db .select({ resourceId: resources.resourceId, name: resources.name, ssl: resources.ssl, fullDomain: resources.fullDomain, passwordId: resourcePassword.passwordId, sso: resources.sso, pincodeId: resourcePincode.pincodeId, whitelist: resources.emailWhitelistEnabled, http: resources.http, protocol: resources.protocol, proxyPort: resources.proxyPort, enabled: resources.enabled, domainId: resources.domainId, niceId: resources.niceId, headerAuthId: resourceHeaderAuth.headerAuthId, headerAuthExtendedCompatibilityId: resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId }) .from(resources) .leftJoin( resourcePassword, eq(resourcePassword.resourceId, resources.resourceId) ) .leftJoin( resourcePincode, eq(resourcePincode.resourceId, resources.resourceId) ) .leftJoin( resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) .leftJoin( resourceHeaderAuthExtendedCompatibility, eq( resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId ) ) .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) ) .groupBy( resources.resourceId, resourcePassword.passwordId, resourcePincode.pincodeId, resourceHeaderAuth.headerAuthId, resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId ); } export type ListResourcesResponse = PaginatedResponse<{ resources: ResourceWithTargets[]; }>; registry.registerPath({ method: "get", path: "/org/{orgId}/resources", description: "List resources for an organization.", tags: [OpenAPITags.PublicResource], request: { params: z.object({ orgId: z.string() }), query: listResourcesSchema }, responses: {} }); export async function listResources( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listResourcesSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedQuery.error) ) ); } const { page, pageSize, authState, enabled, query, healthStatus, sort_by, order } = parsedQuery.data; const parsedParams = listResourcesParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const orgId = parsedParams.data.orgId || req.userOrg?.orgId || req.apiKeyOrg?.orgId; if (!orgId) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") ); } if (req.user && orgId && orgId !== req.userOrgId) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); } let accessibleResources: Array<{ resourceId: number }>; if (req.user) { accessibleResources = await db .select({ resourceId: sql`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})` }) .from(userResources) .fullJoin( roleResources, eq(userResources.resourceId, roleResources.resourceId) ) .where( or( eq(userResources.userId, req.user!.userId), eq(roleResources.roleId, req.userOrgRoleId!) ) ); } else { accessibleResources = await db .select({ resourceId: resources.resourceId }) .from(resources) .where(eq(resources.orgId, orgId)); } const accessibleResourceIds = accessibleResources.map( (resource) => resource.resourceId ); const conditions = [ and( inArray(resources.resourceId, accessibleResourceIds), eq(resources.orgId, 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)); } 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 | 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( sort_by ? order === "asc" ? asc(resources[sort_by]) : desc(resources[sort_by]) : asc(resources.name) ), 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(); for (const row of rows) { let entry = map.get(row.resourceId); if (!entry) { entry = { resourceId: row.resourceId, niceId: row.niceId, name: row.name, ssl: row.ssl, fullDomain: row.fullDomain, passwordId: row.passwordId, sso: row.sso, pincodeId: row.pincodeId, whitelist: row.whitelist, http: row.http, protocol: row.protocol, proxyPort: row.proxyPort, enabled: row.enabled, domainId: row.domainId, headerAuthId: row.headerAuthId, targets: [] }; map.set(row.resourceId, entry); } entry.targets = allResourceTargets.filter( (t) => t.resourceId === entry.resourceId ); } const resourcesList: ResourceWithTargets[] = Array.from(map.values()); return response(res, { data: { resources: resourcesList, pagination: { total: totalCount, pageSize, page } }, success: true, error: false, message: "Resources retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/removeEmailFromResourceWhitelist.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resources, resourceWhitelist } 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 { and, eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const removeEmailFromResourceWhitelistBodySchema = z.strictObject({ email: z .email() .or( z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { error: "Invalid email address. Wildcard (*) must be the entire local part." }) ) .transform((v) => v.toLowerCase()) }); const removeEmailFromResourceWhitelistParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "post", path: "/resource/{resourceId}/whitelist/remove", description: "Remove a single email from the resource whitelist.", tags: [OpenAPITags.PublicResource], request: { params: removeEmailFromResourceWhitelistParamsSchema, body: { content: { "application/json": { schema: removeEmailFromResourceWhitelistBodySchema } } } }, responses: {} }); export async function removeEmailFromResourceWhitelist( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = removeEmailFromResourceWhitelistBodySchema.safeParse( req.body ); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { email } = parsedBody.data; const parsedParams = removeEmailFromResourceWhitelistParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)); if (!resource) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } if (!resource.emailWhitelistEnabled) { return next( createHttpError( HttpCode.BAD_REQUEST, "Email whitelist is not enabled for this resource" ) ); } // Check if email exists in whitelist const existingEntry = await db .select() .from(resourceWhitelist) .where( and( eq(resourceWhitelist.resourceId, resourceId), eq(resourceWhitelist.email, email) ) ); if (existingEntry.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, "Email not found in whitelist" ) ); } await db .delete(resourceWhitelist) .where( and( eq(resourceWhitelist.resourceId, resourceId), eq(resourceWhitelist.email, email) ) ); return response(res, { data: {}, success: true, error: false, message: "Email removed from whitelist successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/removeRoleFromResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; import { roleResources, roles } 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, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const removeRoleFromResourceBodySchema = z .object({ roleId: z.number().int().positive() }) .strict(); const removeRoleFromResourceParamsSchema = z .object({ resourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/resource/{resourceId}/roles/remove", description: "Remove a single role from a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: removeRoleFromResourceParamsSchema, body: { content: { "application/json": { schema: removeRoleFromResourceBodySchema } } } }, responses: {} }); export async function removeRoleFromResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = removeRoleFromResourceBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { roleId } = parsedBody.data; const parsedParams = removeRoleFromResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; // get the resource const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } // Check if the role is an admin role const [roleToCheck] = await db .select() .from(roles) .where( and(eq(roles.roleId, roleId), eq(roles.orgId, resource.orgId)) ) .limit(1); if (!roleToCheck) { return next( createHttpError( HttpCode.NOT_FOUND, "Role not found or does not belong to the same organization" ) ); } if (roleToCheck.isAdmin) { return next( createHttpError( HttpCode.BAD_REQUEST, "Admin role cannot be removed from resources" ) ); } // Check if role exists in resource const existingEntry = await db .select() .from(roleResources) .where( and( eq(roleResources.resourceId, resourceId), eq(roleResources.roleId, roleId) ) ); if (existingEntry.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, "Role not found in resource" ) ); } await db .delete(roleResources) .where( and( eq(roleResources.resourceId, resourceId), eq(roleResources.roleId, roleId) ) ); return response(res, { data: {}, success: true, error: false, message: "Role removed from resource successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/removeUserFromResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; import { userResources } 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, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const removeUserFromResourceBodySchema = z .object({ userId: z.string() }) .strict(); const removeUserFromResourceParamsSchema = z .object({ resourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/resource/{resourceId}/users/remove", description: "Remove a single user from a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: removeUserFromResourceParamsSchema, body: { content: { "application/json": { schema: removeUserFromResourceBodySchema } } } }, responses: {} }); export async function removeUserFromResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = removeUserFromResourceBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { userId } = parsedBody.data; const parsedParams = removeUserFromResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; // get the resource const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } // Check if user exists in resource const existingEntry = await db .select() .from(userResources) .where( and( eq(userResources.resourceId, resourceId), eq(userResources.userId, userId) ) ); if (existingEntry.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, "User not found in resource" ) ); } await db .delete(userResources) .where( and( eq(userResources.resourceId, resourceId), eq(userResources.userId, userId) ) ); return response(res, { data: {}, success: true, error: false, message: "User removed from resource successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/setResourceHeaderAuth.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import { response } from "@server/lib/response"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import { OpenAPITags, registry } from "@server/openApi"; const setResourceAuthMethodsParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); const setResourceAuthMethodsBodySchema = z.strictObject({ user: z.string().min(4).max(100).nullable(), password: z.string().min(4).max(100).nullable(), extendedCompatibility: z.boolean().nullable() }); registry.registerPath({ method: "post", path: "/resource/{resourceId}/header-auth", description: "Set or update the header authentication for a resource. If user and password is not provided, it will remove the header authentication.", tags: [OpenAPITags.PublicResource], request: { params: setResourceAuthMethodsParamsSchema, body: { content: { "application/json": { schema: setResourceAuthMethodsBodySchema } } } }, responses: {} }); export async function setResourceHeaderAuth( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = setResourceAuthMethodsParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = setResourceAuthMethodsBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { resourceId } = parsedParams.data; const { user, password, extendedCompatibility } = parsedBody.data; await db.transaction(async (trx) => { await trx .delete(resourceHeaderAuth) .where(eq(resourceHeaderAuth.resourceId, resourceId)); await trx .delete(resourceHeaderAuthExtendedCompatibility) .where( eq( resourceHeaderAuthExtendedCompatibility.resourceId, resourceId ) ); if (user && password && extendedCompatibility !== null) { const headerAuthHash = await hashPassword( Buffer.from(`${user}:${password}`).toString("base64") ); await Promise.all([ trx .insert(resourceHeaderAuth) .values({ resourceId, headerAuthHash }), trx .insert(resourceHeaderAuthExtendedCompatibility) .values({ resourceId, extendedCompatibilityIsActivated: extendedCompatibility }) ]); } }); return response(res, { data: {}, success: true, error: false, message: "Header Authentication set successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/setResourcePassword.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resourcePassword } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import { hash } from "@node-rs/argon2"; import { response } from "@server/lib/response"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import { OpenAPITags, registry } from "@server/openApi"; const setResourceAuthMethodsParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); const setResourceAuthMethodsBodySchema = z.strictObject({ password: z.string().min(4).max(100).nullable() }); registry.registerPath({ method: "post", path: "/resource/{resourceId}/password", description: "Set the password for a resource. Setting the password to null will remove it.", tags: [OpenAPITags.PublicResource], request: { params: setResourceAuthMethodsParamsSchema, body: { content: { "application/json": { schema: setResourceAuthMethodsBodySchema } } } }, responses: {} }); export async function setResourcePassword( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = setResourceAuthMethodsParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = setResourceAuthMethodsBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { resourceId } = parsedParams.data; const { password } = parsedBody.data; await db.transaction(async (trx) => { await trx .delete(resourcePassword) .where(eq(resourcePassword.resourceId, resourceId)); if (password) { const passwordHash = await hashPassword(password); await trx .insert(resourcePassword) .values({ resourceId, passwordHash }); } }); return response(res, { data: {}, success: true, error: false, message: "Resource password set successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/setResourcePincode.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resourcePincode } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { fromError } from "zod-validation-error"; import { hash } from "@node-rs/argon2"; import { response } from "@server/lib/response"; import stoi from "@server/lib/stoi"; import logger from "@server/logger"; import { hashPassword } from "@server/auth/password"; import { OpenAPITags, registry } from "@server/openApi"; const setResourceAuthMethodsParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); const setResourceAuthMethodsBodySchema = z.strictObject({ pincode: z .string() .regex(/^\d{6}$/) .or(z.null()) }); registry.registerPath({ method: "post", path: "/resource/{resourceId}/pincode", description: "Set the PIN code for a resource. Setting the PIN code to null will remove it.", tags: [OpenAPITags.PublicResource], request: { params: setResourceAuthMethodsParamsSchema, body: { content: { "application/json": { schema: setResourceAuthMethodsBodySchema } } } }, responses: {} }); export async function setResourcePincode( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = setResourceAuthMethodsParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = setResourceAuthMethodsBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { resourceId } = parsedParams.data; const { pincode } = parsedBody.data; await db.transaction(async (trx) => { await trx .delete(resourcePincode) .where(eq(resourcePincode.resourceId, resourceId)); if (pincode) { const pincodeHash = await hashPassword(pincode); await trx .insert(resourcePincode) .values({ resourceId, pincodeHash, digitLength: 6 }); } }); return response(res, { data: {}, success: true, error: false, message: "Resource PIN code set successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/setResourceRoles.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; import { apiKeys, roleResources, roles } 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, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const setResourceRolesBodySchema = z.strictObject({ roleIds: z.array(z.int().positive()) }); const setResourceRolesParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "post", path: "/resource/{resourceId}/roles", description: "Set roles for a resource. This will replace all existing roles.", tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: setResourceRolesParamsSchema, body: { content: { "application/json": { schema: setResourceRolesBodySchema } } } }, responses: {} }); export async function setResourceRoles( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = setResourceRolesBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { roleIds } = parsedBody.data; const parsedParams = setResourceRolesParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; // get the resource const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Resource not found" ) ); } // Check if any of the roleIds are admin roles const rolesToCheck = await db .select() .from(roles) .where( and( inArray(roles.roleId, roleIds), eq(roles.orgId, resource.orgId) ) ); const hasAdminRole = rolesToCheck.some((role) => role.isAdmin); if (hasAdminRole) { return next( createHttpError( HttpCode.BAD_REQUEST, "Admin role cannot be assigned to resources" ) ); } // Get all admin role IDs for this org to exclude from deletion const adminRoles = await db .select() .from(roles) .where( and(eq(roles.isAdmin, true), eq(roles.orgId, resource.orgId)) ); const adminRoleIds = adminRoles.map((role) => role.roleId); await db.transaction(async (trx) => { if (adminRoleIds.length > 0) { await trx.delete(roleResources).where( and( eq(roleResources.resourceId, resourceId), ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role ) ); } else { await trx .delete(roleResources) .where(eq(roleResources.resourceId, resourceId)); } const newRoleResources = await Promise.all( roleIds.map((roleId) => trx .insert(roleResources) .values({ roleId, resourceId }) .returning() ) ); return response(res, { data: {}, success: true, error: false, message: "Roles set for resource successfully", status: HttpCode.CREATED }); }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/setResourceUsers.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { userResources } 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 setUserResourcesBodySchema = z.strictObject({ userIds: z.array(z.string()) }); const setUserResourcesParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "post", path: "/resource/{resourceId}/users", description: "Set users for a resource. This will replace all existing users.", tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: setUserResourcesParamsSchema, body: { content: { "application/json": { schema: setUserResourcesBodySchema } } } }, responses: {} }); export async function setResourceUsers( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = setUserResourcesBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { userIds } = parsedBody.data; const parsedParams = setUserResourcesParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; await db.transaction(async (trx) => { await trx .delete(userResources) .where(eq(userResources.resourceId, resourceId)); const newUserResources = await Promise.all( userIds.map((userId) => trx .insert(userResources) .values({ userId, resourceId }) .returning() ) ); return response(res, { data: {}, success: true, error: false, message: "Users set for resource successfully", status: HttpCode.CREATED }); }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/setResourceWhitelist.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resources, resourceWhitelist } 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 { and, eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; const setResourceWhitelistBodySchema = z.strictObject({ emails: z .array( z.email().or( z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { error: "Invalid email address. Wildcard (*) must be the entire local part." }) ) ) .max(50) .transform((v) => v.map((e) => e.toLowerCase())) }); const setResourceWhitelistParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "post", path: "/resource/{resourceId}/whitelist", description: "Set email whitelist for a resource. This will replace all existing emails.", tags: [OpenAPITags.PublicResource], request: { params: setResourceWhitelistParamsSchema, body: { content: { "application/json": { schema: setResourceWhitelistBodySchema } } } }, responses: {} }); export async function setResourceWhitelist( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = setResourceWhitelistBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { emails } = parsedBody.data; const parsedParams = setResourceWhitelistParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)); if (!resource) { return next( createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); } if (!resource.emailWhitelistEnabled) { return next( createHttpError( HttpCode.BAD_REQUEST, "Email whitelist is not enabled for this resource" ) ); } const whitelist = await db .select() .from(resourceWhitelist) .where(eq(resourceWhitelist.resourceId, resourceId)); await db.transaction(async (trx) => { // diff the emails const existingEmails = whitelist.map((w) => w.email); const emailsToAdd = emails.filter( (e) => !existingEmails.includes(e) ); const emailsToRemove = existingEmails.filter( (e) => !emails.includes(e) ); for (const email of emailsToAdd) { await trx.insert(resourceWhitelist).values({ email, resourceId }); } for (const email of emailsToRemove) { await trx .delete(resourceWhitelist) .where( and( eq(resourceWhitelist.resourceId, resourceId), eq(resourceWhitelist.email, email) ) ); } return response(res, { data: {}, success: true, error: false, message: "Whitelist set for resource successfully", status: HttpCode.CREATED }); }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/resource/types.ts ================================================ export type GetMaintenanceInfoResponse = { resourceId: number; name: string; fullDomain: string | null; maintenanceModeEnabled: boolean; maintenanceModeType: "forced" | "automatic" | null; maintenanceTitle: string | null; maintenanceMessage: string | null; maintenanceEstimatedTime: string | null; }; ================================================ FILE: server/routers/resource/updateResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, loginPage } from "@server/db"; import { domains, Org, orgDomains, orgs, Resource, resources } from "@server/db"; import { eq, and, ne } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import config from "@server/lib/config"; import { tlsNameSchema } from "@server/lib/schemas"; import { subdomainSchema } from "@server/lib/schemas"; import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; const updateResourceParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); const updateHttpResourceBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), niceId: z .string() .min(1) .max(255) .regex( /^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes" ) .optional(), subdomain: subdomainSchema.nullable().optional(), ssl: z.boolean().optional(), sso: z.boolean().optional(), blockAccess: z.boolean().optional(), emailWhitelistEnabled: z.boolean().optional(), applyRules: z.boolean().optional(), domainId: z.string().optional(), enabled: z.boolean().optional(), stickySession: z.boolean().optional(), tlsServerName: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(), skipToIdpId: z.int().positive().nullable().optional(), headers: z .array(z.strictObject({ name: z.string(), value: z.string() })) .nullable() .optional(), // Maintenance mode fields maintenanceModeEnabled: z.boolean().optional(), maintenanceModeType: z.enum(["forced", "automatic"]).optional(), maintenanceTitle: z.string().max(255).nullable().optional(), maintenanceMessage: z.string().max(2000).nullable().optional(), maintenanceEstimatedTime: z.string().max(100).nullable().optional(), postAuthPath: z.string().nullable().optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" }) .refine( (data) => { if (data.subdomain) { return subdomainSchema.safeParse(data.subdomain).success; } return true; }, { error: "Invalid subdomain" } ) .refine( (data) => { if (data.tlsServerName) { return tlsNameSchema.safeParse(data.tlsServerName).success; } return true; }, { error: "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name." } ) .refine( (data) => { if (data.setHostHeader) { return tlsNameSchema.safeParse(data.setHostHeader).success; } return true; }, { error: "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." } ) .refine( (data) => { if (data.headers) { // HTTP header names must be valid token characters (RFC 7230) const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/; return data.headers.every((h) => validHeaderName.test(h.name)); } return true; }, { error: "Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~)." } ) .refine( (data) => { if (data.headers) { // HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230) const validHeaderValue = /^[\t\x20-\x7E]*$/; return data.headers.every((h) => validHeaderValue.test(h.value)); } return true; }, { error: "Header values may only contain printable ASCII characters and horizontal whitespace." } ) .refine( (data) => { if (data.headers) { // Reject Traefik template syntax {{word}} in names or values const templatePattern = /\{\{[^}]+\}\}/; return data.headers.every( (h) => !templatePattern.test(h.name) && !templatePattern.test(h.value) ); } return true; }, { error: "Header names and values must not contain template expressions such as {{value}}." } ); export type UpdateResourceResponse = Resource; const updateRawResourceBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(), proxyPort: z.int().min(1).max(65535).optional(), stickySession: z.boolean().optional(), enabled: z.boolean().optional(), proxyProtocol: z.boolean().optional(), proxyProtocolVersion: z.int().min(1).optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" }) .refine( (data) => { if (!config.getRawConfig().flags?.allow_raw_resources) { if (data.proxyPort !== undefined) { return false; } } return true; }, { error: "Cannot update proxyPort" } ); registry.registerPath({ method: "post", path: "/resource/{resourceId}", description: "Update a resource.", tags: [OpenAPITags.PublicResource], request: { params: updateResourceParamsSchema, body: { content: { "application/json": { schema: updateHttpResourceBodySchema.and( updateRawResourceBodySchema ) } } } }, responses: {} }); export async function updateResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = updateResourceParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const [result] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .leftJoin(orgs, eq(resources.orgId, orgs.orgId)); const resource = result.resources; const org = result.orgs; if (!resource || !org) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } if (resource.http) { // HANDLE UPDATING HTTP RESOURCES return await updateHttpResource( { req, res, next }, { resource, org } ); } else { // HANDLE UPDATING RAW TCP/UDP RESOURCES return await updateRawResource( { req, res, next }, { resource, org } ); } } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } async function updateHttpResource( route: { req: Request; res: Response; next: NextFunction; }, meta: { resource: Resource; org: Org; } ) { const { next, req, res } = route; const { resource, org } = meta; const parsedBody = updateHttpResourceBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const updateData = parsedBody.data; if (updateData.niceId) { const [existingResource] = await db .select() .from(resources) .where( and( eq(resources.niceId, updateData.niceId), eq(resources.orgId, resource.orgId), ne(resources.resourceId, resource.resourceId) // exclude the current resource from the search ) ) .limit(1); if (existingResource) { return next( createHttpError( HttpCode.CONFLICT, `A resource with niceId "${updateData.niceId}" already exists` ) ); } } if (updateData.domainId) { const domainId = updateData.domainId; // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( domainId, resource.orgId, updateData.subdomain ); if (!domainResult.success) { return next( createHttpError(HttpCode.BAD_REQUEST, domainResult.error) ); } const { fullDomain, subdomain: finalSubdomain } = domainResult; logger.debug(`Full domain: ${fullDomain}`); if (fullDomain) { const [existingDomain] = await db .select() .from(resources) .where(eq(resources.fullDomain, fullDomain)); if ( existingDomain && existingDomain.resourceId !== resource.resourceId ) { return next( createHttpError( HttpCode.CONFLICT, "Resource with that domain already exists" ) ); } // Prevent updating resource with same domain as dashboard const dashboardUrl = config.getRawConfig().app.dashboard_url; if (dashboardUrl) { const dashboardHost = new URL(dashboardUrl).hostname; if (fullDomain === dashboardHost) { return next( createHttpError( HttpCode.CONFLICT, "Resource domain cannot be the same as the dashboard domain" ) ); } } if (build != "oss") { const existingLoginPages = await db .select() .from(loginPage) .where(eq(loginPage.fullDomain, fullDomain)); if (existingLoginPages.length > 0) { return next( createHttpError( HttpCode.CONFLICT, "Login page with that domain already exists" ) ); } } } // update the full domain if it has changed if (fullDomain && fullDomain !== resource.fullDomain) { await db .update(resources) .set({ fullDomain }) .where(eq(resources.resourceId, resource.resourceId)); } // Update the subdomain in the update data updateData.subdomain = finalSubdomain; if (build != "oss") { await createCertificate(domainId, fullDomain, db); } } let headers = undefined; if (updateData.headers) { headers = JSON.stringify(updateData.headers); } else if (updateData.headers === null) { headers = null; } const isLicensed = await isLicensedOrSubscribed( resource.orgId, tierMatrix.maintencePage ); if (!isLicensed) { updateData.maintenanceModeEnabled = undefined; updateData.maintenanceModeType = undefined; updateData.maintenanceTitle = undefined; updateData.maintenanceMessage = undefined; updateData.maintenanceEstimatedTime = undefined; } const updatedResource = await db .update(resources) .set({ ...updateData, headers }) .where(eq(resources.resourceId, resource.resourceId)) .returning(); if (updatedResource.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resource.resourceId} not found` ) ); } return response(res, { data: updatedResource[0], success: true, error: false, message: "HTTP resource updated successfully", status: HttpCode.OK }); } async function updateRawResource( route: { req: Request; res: Response; next: NextFunction; }, meta: { resource: Resource; org: Org; } ) { const { next, req, res } = route; const { resource } = meta; const parsedBody = updateRawResourceBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const updateData = parsedBody.data; if (updateData.niceId) { const [existingResource] = await db .select() .from(resources) .where( and( eq(resources.niceId, updateData.niceId), eq(resources.orgId, resource.orgId) ) ); if ( existingResource && existingResource.resourceId !== resource.resourceId ) { return next( createHttpError( HttpCode.CONFLICT, `A resource with niceId "${updateData.niceId}" already exists` ) ); } } const updatedResource = await db .update(resources) .set(updateData) .where(eq(resources.resourceId, resource.resourceId)) .returning(); if (updatedResource.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resource.resourceId} not found` ) ); } return response(res, { data: updatedResource[0], success: true, error: false, message: "Non-http Resource updated successfully", status: HttpCode.OK }); } ================================================ FILE: server/routers/resource/updateResourceRule.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resourceRules, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; // Define Zod schema for request parameters validation const updateResourceRuleParamsSchema = z.strictObject({ ruleId: z.string().transform(Number).pipe(z.int().positive()), resourceId: z.string().transform(Number).pipe(z.int().positive()) }); // Define Zod schema for request body validation const updateResourceRuleSchema = z .strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), match: z.enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN"]).optional(), value: z.string().min(1).optional(), priority: z.int(), enabled: z.boolean().optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" }); registry.registerPath({ method: "post", path: "/resource/{resourceId}/rule/{ruleId}", description: "Update a resource rule.", tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: updateResourceRuleParamsSchema, body: { content: { "application/json": { schema: updateResourceRuleSchema } } } }, responses: {} }); export async function updateResourceRule( req: Request, res: Response, next: NextFunction ): Promise { try { // Validate path parameters const parsedParams = updateResourceRuleParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } // Validate request body const parsedBody = updateResourceRuleSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { ruleId, resourceId } = parsedParams.data; const updateData = parsedBody.data; // Verify that the resource exists const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } if (!resource.http) { return next( createHttpError( HttpCode.BAD_REQUEST, "Cannot create rule for non-http resource" ) ); } // Verify that the rule exists and belongs to the specified resource const [existingRule] = await db .select() .from(resourceRules) .where(eq(resourceRules.ruleId, ruleId)) .limit(1); if (!existingRule) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource rule with ID ${ruleId} not found` ) ); } if (existingRule.resourceId !== resourceId) { return next( createHttpError( HttpCode.FORBIDDEN, `Resource rule ${ruleId} does not belong to resource ${resourceId}` ) ); } const match = updateData.match || existingRule.match; const { value } = updateData; if (value !== undefined) { if (match === "CIDR") { if (!isValidCIDR(value)) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid CIDR provided" ) ); } } else if (match === "IP") { if (!isValidIP(value)) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid IP provided" ) ); } } else if (match === "PATH") { if (!isValidUrlGlobPattern(value)) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid URL glob pattern provided" ) ); } } } // Update the rule const [updatedRule] = await db .update(resourceRules) .set(updateData) .where(eq(resourceRules.ruleId, ruleId)) .returning(); return response(res, { data: updatedRule, success: true, error: false, message: "Resource rule updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/addRoleAction.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roleActions, roles } 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 { eq } from "drizzle-orm"; import { fromError } from "zod-validation-error"; const addRoleActionParamSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); const addRoleActionSchema = z.strictObject({ actionId: z.string() }); export async function addRoleAction( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = addRoleActionSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { actionId } = parsedBody.data; const parsedParams = addRoleActionParamSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { roleId } = parsedParams.data; const role = await db .select({ orgId: roles.orgId }) .from(roles) .where(eq(roles.roleId, roleId)) .limit(1); if (role.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Role with ID ${roleId} not found` ) ); } const newRoleAction = await db .insert(roleActions) .values({ roleId, actionId, orgId: role[0].orgId! }) .returning(); return response(res, { data: newRoleAction[0], success: true, error: false, message: "Action added to role successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/addRoleSite.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resources, roleResources, roleSites } 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 { eq } from "drizzle-orm"; import { fromError } from "zod-validation-error"; const addRoleSiteParamsSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); const addRoleSiteSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) }); export async function addRoleSite( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = addRoleSiteSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { siteId } = parsedBody.data; const parsedParams = addRoleSiteParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { roleId } = parsedParams.data; await db.transaction(async (trx) => { const newRoleSite = await trx .insert(roleSites) .values({ roleId, siteId }) .returning(); // const siteResources = await db // .select() // .from(resources) // .where(eq(resources.siteId, siteId)); // // for (const resource of siteResources) { // await trx.insert(roleResources).values({ // roleId, // resourceId: resource.resourceId // }); // } // return response(res, { data: newRoleSite[0], success: true, error: false, message: "Site added to role successfully", status: HttpCode.CREATED }); }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/createRole.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { orgs, Role, roleActions, roles } 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 { ActionsEnum } from "@server/auth/actions"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; 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(), 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[] = [ ActionsEnum.getOrg, ActionsEnum.getResource, ActionsEnum.listResources ]; export type CreateRoleBody = z.infer; export type CreateRoleResponse = Role; registry.registerPath({ method: "put", path: "/org/{orgId}/role", description: "Create a role.", tags: [OpenAPITags.Role], request: { params: createRoleParamsSchema, body: { content: { "application/json": { schema: createRoleSchema } } } }, responses: {} }); export async function createRole( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = createRoleSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const roleData = parsedBody.data; const parsedParams = createRoleParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const allRoles = await db .select({ roleId: roles.roleId, name: roles.name }) .from(roles) .leftJoin(orgs, eq(roles.orgId, orgs.orgId)) .where(and(eq(roles.name, roleData.name), eq(roles.orgId, orgId))); // make sure name is unique if (allRoles.length > 0) { return next( createHttpError( HttpCode.BAD_REQUEST, "Role with that name already exists" ) ); } const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); if (!isLicensedDeviceApprovals) { roleData.requireDeviceApproval = undefined; } const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam); const roleInsertValues: Record = { name: roleData.name, orgId }; if (roleData.description !== undefined) roleInsertValues.description = roleData.description; if (roleData.requireDeviceApproval !== undefined) roleInsertValues.requireDeviceApproval = roleData.requireDeviceApproval; if (isLicensedSshPam) { if (roleData.sshSudoMode !== undefined) roleInsertValues.sshSudoMode = roleData.sshSudoMode; if (roleData.sshSudoCommands !== undefined) roleInsertValues.sshSudoCommands = JSON.stringify(roleData.sshSudoCommands); if (roleData.sshCreateHomeDir !== undefined) roleInsertValues.sshCreateHomeDir = roleData.sshCreateHomeDir; if (roleData.sshUnixGroups !== undefined) roleInsertValues.sshUnixGroups = JSON.stringify(roleData.sshUnixGroups); } await db.transaction(async (trx) => { const newRole = await trx .insert(roles) .values(roleInsertValues as typeof roles.$inferInsert) .returning(); const actionsToInsert = [...defaultRoleAllowedActions]; if (roleData.allowSsh) { actionsToInsert.push(ActionsEnum.signSshKey); } await trx .insert(roleActions) .values( actionsToInsert.map((action) => ({ roleId: newRole[0].roleId, actionId: action, orgId })) ) .execute(); return response(res, { data: newRole[0], success: true, error: false, message: "Role created successfully", status: HttpCode.CREATED }); }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/deleteRole.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roles, userOrgs } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const deleteRoleSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); const deelteRoleBodySchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "delete", path: "/role/{roleId}", description: "Delete a role.", tags: [OpenAPITags.Role], request: { params: deleteRoleSchema, body: { content: { "application/json": { schema: deelteRoleBodySchema } } } }, responses: {} }); export async function deleteRole( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = deleteRoleSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = deelteRoleBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { roleId } = parsedParams.data; const { roleId: newRoleId } = parsedBody.data; if (roleId === newRoleId) { return next( createHttpError( HttpCode.BAD_REQUEST, `Cannot delete a role and assign the same role` ) ); } const role = await db .select() .from(roles) .where(eq(roles.roleId, roleId)) .limit(1); if (role.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Role with ID ${roleId} not found` ) ); } if (role[0].isAdmin) { return next( createHttpError( HttpCode.FORBIDDEN, `Cannot delete a Admin role` ) ); } const newRole = await db .select() .from(roles) .where(eq(roles.roleId, newRoleId)) .limit(1); if (newRole.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Role with ID ${newRoleId} not found` ) ); } await db.transaction(async (trx) => { // move all users from the userOrgs table with roleId to newRoleId await trx .update(userOrgs) .set({ roleId: newRoleId }) .where(eq(userOrgs.roleId, roleId)); // delete the old role await trx.delete(roles).where(eq(roles.roleId, roleId)); }); return response(res, { data: null, success: true, error: false, message: "Role deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/getRole.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roles } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const getRoleSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "get", path: "/role/{roleId}", description: "Get a role.", tags: [OpenAPITags.Role], request: { params: getRoleSchema }, responses: {} }); export async function getRole( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getRoleSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { roleId } = parsedParams.data; const role = await db .select() .from(roles) .where(eq(roles.roleId, roleId)) .limit(1); if (role.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Role with ID ${roleId} not found` ) ); } return response(res, { data: role[0], success: true, error: false, message: "Role retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/index.ts ================================================ export * from "./addRoleAction"; export * from "../resource/setResourceRoles"; export * from "./createRole"; export * from "./deleteRole"; export * from "./getRole"; export * from "./index"; export * from "./listRoleActions"; export * from "./listRoleResources"; export * from "./listRoles"; export * from "./listRoleSites"; export * from "./removeRoleAction"; export * from "./removeRoleResource"; export * from "./updateRole"; ================================================ FILE: server/routers/role/listRoleActions.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roleActions, actions } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const listRoleActionsSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); export async function listRoleActions( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listRoleActionsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { roleId } = parsedParams.data; const roleActionsList = await db .select({ actionId: actions.actionId, name: actions.name, description: actions.description }) .from(roleActions) .innerJoin(actions, eq(roleActions.actionId, actions.actionId)) .where(eq(roleActions.roleId, roleId)); // TODO: Do we need to filter out what the user can see? return response(res, { data: roleActionsList, success: true, error: false, message: "Role actions retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/listRoleResources.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roleResources, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const listRoleResourcesSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); export async function listRoleResources( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listRoleResourcesSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { roleId } = parsedParams.data; const roleResourcesList = await db .select({ resourceId: resources.resourceId, name: resources.name, subdomain: resources.subdomain }) .from(roleResources) .innerJoin( resources, eq(roleResources.resourceId, resources.resourceId) ) .where(eq(roleResources.roleId, roleId)); // TODO: Do we need to filter out what the user can see? return response(res, { data: roleResourcesList, success: true, error: false, message: "Role resources retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/listRoleSites.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roleSites, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const listRoleSitesSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); export async function listRoleSites( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listRoleSitesSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { roleId } = parsedParams.data; const roleSitesList = await db .select({ siteId: sites.siteId, name: sites.name }) .from(roleSites) .innerJoin(sites, eq(roleSites.siteId, sites.siteId)) .where(eq(roleSites.roleId, roleId)); // TODO: Do we need to filter out what the user can see? return response(res, { data: roleSitesList, success: true, error: false, message: "Role sites retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/listRoles.ts ================================================ 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 { 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 { object, z } from "zod"; import { fromError } from "zod-validation-error"; const listRolesParamsSchema = z.strictObject({ orgId: z.string() }); const listRolesSchema = z.object({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().nonnegative()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); async function queryRoles(orgId: string, limit: number, offset: number) { return await db .select({ roleId: roles.roleId, orgId: roles.orgId, isAdmin: roles.isAdmin, name: roles.name, description: roles.description, orgName: orgs.name, requireDeviceApproval: roles.requireDeviceApproval, sshSudoMode: roles.sshSudoMode, sshSudoCommands: roles.sshSudoCommands, sshCreateHomeDir: roles.sshCreateHomeDir, sshUnixGroups: roles.sshUnixGroups }) .from(roles) .leftJoin(orgs, eq(roles.orgId, orgs.orgId)) .where(eq(roles.orgId, orgId)) .limit(limit) .offset(offset); } export type ListRolesResponse = { roles: NonNullable>>; pagination: { total: number; limit: number; offset: number; }; }; registry.registerPath({ method: "get", path: "/org/{orgId}/roles", description: "List roles.", tags: [OpenAPITags.Role], request: { params: listRolesParamsSchema, query: listRolesSchema }, responses: {} }); export async function listRoles( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listRolesSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = listRolesParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const countQuery: any = db .select({ count: sql`cast(count(*) as integer)` }) .from(roles) .where(eq(roles.orgId, orgId)); const rolesList = await queryRoles(orgId, limit, offset); 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: rolesWithAllowSsh, pagination: { total: totalCount, limit, offset } }, success: true, error: false, message: "Roles retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/removeRoleAction.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roleActions } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const removeRoleActionParamsSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); const removeRoleActionSchema = z.strictObject({ actionId: z.string() }); export async function removeRoleAction( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = removeRoleActionSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { actionId } = parsedParams.data; const parsedBody = removeRoleActionParamsSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { roleId } = parsedBody.data; const deletedRoleAction = await db .delete(roleActions) .where( and( eq(roleActions.roleId, roleId), eq(roleActions.actionId, actionId) ) ) .returning(); if (deletedRoleAction.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Action with ID ${actionId} not found for role with ID ${roleId}` ) ); } return response(res, { data: null, success: true, error: false, message: "Action removed from role successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/removeRoleResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roleResources } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const removeRoleResourceParamsSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); const removeRoleResourceSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); export async function removeRoleResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = removeRoleResourceSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; const parsedBody = removeRoleResourceParamsSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { roleId } = parsedBody.data; const deletedRoleResource = await db .delete(roleResources) .where( and( eq(roleResources.roleId, roleId), eq(roleResources.resourceId, resourceId) ) ) .returning(); if (deletedRoleResource.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found for role with ID ${roleId}` ) ); } return response(res, { data: null, success: true, error: false, message: "Resource removed from role successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/removeRoleSite.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resources, roleResources, roleSites } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const removeRoleSiteParamsSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); const removeRoleSiteSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) }); export async function removeRoleSite( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = removeRoleSiteSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteId } = parsedParams.data; const parsedBody = removeRoleSiteParamsSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { roleId } = parsedBody.data; await db.transaction(async (trx) => { const deletedRoleSite = await trx .delete(roleSites) .where( and( eq(roleSites.roleId, roleId), eq(roleSites.siteId, siteId) ) ) .returning(); if (deletedRoleSite.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Site with ID ${siteId} not found for role with ID ${roleId}` ) ); } // const siteResources = await db // .select() // .from(resources) // .where(eq(resources.siteId, siteId)); // // for (const resource of siteResources) { // await trx // .delete(roleResources) // .where( // and( // eq(roleResources.roleId, roleId), // eq(roleResources.resourceId, resource.resourceId) // ) // ) // .returning(); // } }); return response(res, { data: null, success: true, error: false, message: "Site removed from role successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/role/updateRole.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, type Role } from "@server/db"; 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"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { OpenAPITags, registry } from "@server/openApi"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; 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(), 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" }); export type UpdateRoleBody = z.infer; export type UpdateRoleResponse = Role; registry.registerPath({ method: "post", path: "/role/{roleId}", description: "Update a role.", tags: [OpenAPITags.Role], request: { params: updateRoleParamsSchema, body: { content: { "application/json": { schema: updateRoleBodySchema } } } }, responses: {} }); export async function updateRole( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = updateRoleParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = updateRoleBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { roleId } = parsedParams.data; const body = parsedBody.data; const { allowSsh, ...restBody } = body; const updateData: Record = { ...restBody }; const role = await db .select() .from(roles) .where(eq(roles.roleId, roleId)) .limit(1); if (role.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Role with ID ${roleId} not found` ) ); } const orgId = role[0].orgId; const isAdminRole = role[0].isAdmin; if (isAdminRole) { delete updateData.name; delete updateData.description; } if (!orgId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Role does not have an organization ID" ) ); } const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); if (!isLicensedDeviceApprovals) { updateData.requireDeviceApproval = undefined; } 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); } } 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, `Role with ID ${roleId} not found` ) ); } return response(res, { data: updatedRole, success: true, error: false, message: "Role updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/serverInfo/getServerInfo.ts ================================================ import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import config from "@server/lib/config"; import { build } from "@server/build"; import { APP_VERSION } from "@server/lib/consts"; import license from "#dynamic/license/license"; export type GetServerInfoResponse = { version: string; supporterStatusValid: boolean; build: "oss" | "enterprise" | "saas"; enterpriseLicenseValid: boolean; enterpriseLicenseType: string | null; }; export async function getServerInfo( req: Request, res: Response, next: NextFunction ): Promise { try { const supporterData = config.getSupporterData(); const supporterStatusValid = supporterData?.valid || false; let enterpriseLicenseValid = false; let enterpriseLicenseType: string | null = null; if (build === "enterprise") { try { const licenseStatus = await license.check(); enterpriseLicenseValid = licenseStatus.isLicenseValid; enterpriseLicenseType = licenseStatus.tier || null; } catch (error) { logger.warn("Failed to check enterprise license status:", error); } } return sendResponse(res, { data: { version: APP_VERSION, supporterStatusValid, build, enterpriseLicenseValid, enterpriseLicenseType }, success: true, error: false, message: "Server info retrieved", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/serverInfo/index.ts ================================================ export * from "./getServerInfo"; ================================================ FILE: server/routers/site/createSite.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { clients, db, exitNodes } from "@server/db"; import { roles, userSites, sites, roleSites, Site, orgs } 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 { eq, and, count } from "drizzle-orm"; import { getUniqueSiteName } from "../../db/names"; import { addPeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { newts } from "@server/db"; import moment from "moment"; import { OpenAPITags, registry } from "@server/openApi"; import { hashPassword } from "@server/auth/password"; import { isValidIP } from "@server/lib/validators"; import { isIpInCidr } from "@server/lib/ip"; import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; const createSiteParamsSchema = z.strictObject({ orgId: z.string() }); const createSiteSchema = z.strictObject({ name: z.string().min(1).max(255), exitNodeId: z.int().positive().optional(), // subdomain: z // .string() // .min(1) // .max(255) // .transform((val) => val.toLowerCase()) // .optional(), pubKey: z.string().optional(), subnet: z.string().optional(), newtId: z.string().optional(), secret: z.string().optional(), address: z.string().optional(), type: z.enum(["newt", "wireguard", "local"]) }); // .refine((data) => { // if (data.type === "local") { // return !config.getRawConfig().flags?.disable_local_sites; // } else if (data.type === "wireguard") { // return !config.getRawConfig().flags?.disable_basic_wireguard_sites; // } // return true; // }); export type CreateSiteBody = z.infer; export type CreateSiteResponse = Site; registry.registerPath({ method: "put", path: "/org/{orgId}/site", description: "Create a new site.", tags: [OpenAPITags.Site], request: { params: createSiteParamsSchema, body: { content: { "application/json": { schema: createSiteSchema } } } }, responses: {} }); export async function createSite( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = createSiteSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { name, type, exitNodeId, pubKey, subnet, newtId, secret, address } = parsedBody.data; const parsedParams = createSiteParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); } const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); if (!org) { return next( createHttpError( HttpCode.NOT_FOUND, `Organization with ID ${orgId} not found` ) ); } if (build == "saas") { const usage = await usageService.getUsage(orgId, FeatureId.SITES); if (!usage) { return next( createHttpError( HttpCode.NOT_FOUND, "No usage data found for this organization" ) ); } const rejectSites = await usageService.checkLimitSet( orgId, FeatureId.SITES, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 } // We need to add one to know if we are violating the limit ); if (rejectSites) { return next( createHttpError( HttpCode.FORBIDDEN, "Site limit exceeded. Please upgrade your plan." ) ); } } let updatedAddress = null; if (address) { if (!org.subnet) { return next( createHttpError( HttpCode.BAD_REQUEST, `Organization with ID ${orgId} has no subnet defined` ) ); } if (!isValidIP(address)) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid address format. Please provide a valid IP notation." ) ); } if (!isIpInCidr(address, org.subnet)) { return next( createHttpError( HttpCode.BAD_REQUEST, "IP is not in the CIDR range of the subnet." ) ); } updatedAddress = `${address}/${org.subnet.split("/")[1]}`; // we want the block size of the whole org // make sure the subnet is unique const addressExistsSites = await db .select() .from(sites) .where( and( eq(sites.address, updatedAddress), eq(sites.orgId, orgId) ) ) .limit(1); if (addressExistsSites.length > 0) { return next( createHttpError( HttpCode.CONFLICT, `Subnet ${updatedAddress} already exists in sites` ) ); } const addressExistsClients = await db .select() .from(clients) .where( and( eq(clients.subnet, updatedAddress), eq(clients.orgId, orgId) ) ) .limit(1); if (addressExistsClients.length > 0) { return next( createHttpError( HttpCode.CONFLICT, `Subnet ${updatedAddress} already exists in clients` ) ); } } if (subnet && exitNodeId) { //make sure the subnet is in the range of the exit node if provided const [exitNode] = await db .select() .from(exitNodes) .where(eq(exitNodes.exitNodeId, exitNodeId)); if (!exitNode) { return next( createHttpError(HttpCode.NOT_FOUND, "Exit node not found") ); } if (!exitNode.address) { return next( createHttpError( HttpCode.BAD_REQUEST, "Exit node has no subnet defined" ) ); } const subnetIp = subnet.split("/")[0]; if (!isIpInCidr(subnetIp, exitNode.address)) { return next( createHttpError( HttpCode.BAD_REQUEST, "Subnet is not in the CIDR range of the exit node address." ) ); } // lets also make sure there is no overlap with other sites on the exit node const sitesQuery = await db .select({ subnet: sites.subnet }) .from(sites) .where( and( eq(sites.exitNodeId, exitNodeId), eq(sites.subnet, subnet) ) ); if (sitesQuery.length > 0) { return next( createHttpError( HttpCode.CONFLICT, `Subnet ${subnet} overlaps with an existing site on this exit node. Please restart site creation.` ) ); } } const niceId = await getUniqueSiteName(orgId); let newSite: Site | undefined; await db.transaction(async (trx) => { if (type == "newt") { [newSite] = await trx .insert(sites) .values({ // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT orgId, name, niceId, address: updatedAddress || null, type, dockerSocketEnabled: true }) .returning(); } else if (type == "wireguard") { // we are creating a site with an exit node (tunneled) if (!subnet) { return next( createHttpError( HttpCode.BAD_REQUEST, "Subnet is required for tunneled sites" ) ); } if (!exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Exit node ID is required for tunneled sites" ) ); } const { exitNode, hasAccess } = await verifyExitNodeOrgAccess( exitNodeId, orgId ); if (!exitNode) { logger.warn("Exit node not found"); return next( createHttpError( HttpCode.NOT_FOUND, "Exit node not found" ) ); } if (!hasAccess) { logger.warn("Not authorized to use this exit node"); return next( createHttpError( HttpCode.FORBIDDEN, "Not authorized to use this exit node" ) ); } [newSite] = await trx .insert(sites) .values({ orgId, exitNodeId, name, niceId, subnet, type, pubKey: pubKey || null }) .returning(); } else if (type == "local") { [newSite] = await trx .insert(sites) .values({ exitNodeId: exitNodeId || null, orgId, name, niceId, address: updatedAddress || null, type, dockerSocketEnabled: false, online: true, subnet: "0.0.0.0/32" }) .returning(); } else { return next( createHttpError( HttpCode.BAD_REQUEST, "Site type not recognized" ) ); } const adminRole = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .limit(1); if (adminRole.length === 0) { return next( createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) ); } await trx.insert(roleSites).values({ roleId: adminRole[0].roleId, siteId: newSite.siteId }); if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the site trx.insert(userSites).values({ userId: req.user?.userId!, siteId: newSite.siteId }); } // add the peer to the exit node if (type == "newt") { const secretHash = await hashPassword(secret!); await trx.insert(newts).values({ newtId: newtId!, secretHash, siteId: newSite.siteId, dateCreated: moment().toISOString() }); } else if (type == "wireguard") { if (!pubKey) { return next( createHttpError( HttpCode.BAD_REQUEST, "Public key is required for wireguard sites" ) ); } if (!exitNodeId) { return next( createHttpError( HttpCode.BAD_REQUEST, "Exit node ID is required for wireguard sites" ) ); } await addPeer(exitNodeId, { publicKey: pubKey, allowedIps: [] }); } await usageService.add(orgId, FeatureId.SITES, 1, trx); }); if (!newSite) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create site" ) ); } return response(res, { data: newSite, success: true, error: false, message: "Site created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/site/deleteSite.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, Site, siteResources } from "@server/db"; import { newts, newtSessions, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { deletePeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { sendToClient } from "#dynamic/routers/ws"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; const deleteSiteSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "delete", path: "/site/{siteId}", description: "Delete a site and all its associated data.", tags: [OpenAPITags.Site], request: { params: deleteSiteSchema }, responses: {} }); export async function deleteSite( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = deleteSiteSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteId } = parsedParams.data; const [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { return next( createHttpError( HttpCode.NOT_FOUND, `Site with ID ${siteId} not found` ) ); } let deletedNewtId: string | null = null; await db.transaction(async (trx) => { if (site.type == "wireguard") { if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); } } else if (site.type == "newt") { // delete all of the site resources on this site const siteResourcesOnSite = trx .delete(siteResources) .where(eq(siteResources.siteId, siteId)) .returning(); // loop through them for (const removedSiteResource of await siteResourcesOnSite) { await rebuildClientAssociationsFromSiteResource( removedSiteResource, trx ); } // get the newt on the site by querying the newt table for siteId const [deletedNewt] = await trx .delete(newts) .where(eq(newts.siteId, siteId)) .returning(); if (deletedNewt) { deletedNewtId = deletedNewt.newtId; // delete all of the sessions for the newt await trx .delete(newtSessions) .where(eq(newtSessions.newtId, deletedNewt.newtId)); } } await trx.delete(sites).where(eq(sites.siteId, siteId)); await usageService.add(site.orgId, FeatureId.SITES, -1, trx); }); // Send termination message outside of transaction to prevent blocking if (deletedNewtId) { const payload = { type: `newt/wg/terminate`, data: {} }; // Don't await this to prevent blocking the response sendToClient(deletedNewtId, payload).catch((error) => { logger.error( "Failed to send termination message to newt:", error ); }); } return response(res, { data: null, success: true, error: false, message: "Site deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/site/getSite.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, newts } from "@server/db"; import { sites } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const getSiteSchema = z.strictObject({ siteId: z .string() .optional() .transform(stoi) .pipe(z.int().positive().optional()) .optional(), niceId: z.string().optional(), orgId: z.string().optional() }); async function query(siteId?: number, niceId?: string, orgId?: string) { if (siteId) { const [res] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .leftJoin(newts, eq(sites.siteId, newts.siteId)) .limit(1); return res; } else if (niceId && orgId) { const [res] = await db .select() .from(sites) .where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId))) .leftJoin(newts, eq(sites.siteId, newts.siteId)) .limit(1); return res; } } export type GetSiteResponse = NonNullable< Awaited> >["sites"] & { newtId: string | null }; registry.registerPath({ method: "get", path: "/org/{orgId}/site/{niceId}", description: "Get a site by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.", tags: [OpenAPITags.Site], request: { params: z.object({ orgId: z.string(), niceId: z.string() }) }, responses: {} }); registry.registerPath({ method: "get", path: "/site/{siteId}", description: "Get a site by siteId.", tags: [OpenAPITags.Site], request: { params: z.object({ siteId: z.number() }) }, responses: {} }); export async function getSite( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getSiteSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteId, niceId, orgId } = parsedParams.data; const site = await query(siteId, niceId, orgId); if (!site) { return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } const data: GetSiteResponse = { ...site.sites, newtId: site.newt ? site.newt.newtId : null }; return response(res, { data, success: true, error: false, message: "Site retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/site/index.ts ================================================ export * from "./getSite"; export * from "./createSite"; export * from "./deleteSite"; export * from "./updateSite"; export * from "./listSites"; export * from "./listSiteRoles"; export * from "./pickSiteDefaults"; export * from "./socketIntegration"; ================================================ FILE: server/routers/site/listSiteRoles.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roleSites, roles } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const listSiteRolesSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) }); export async function listSiteRoles( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listSiteRolesSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteId } = parsedParams.data; const siteRolesList = await db .select({ roleId: roles.roleId, name: roles.name, description: roles.description, isAdmin: roles.isAdmin }) .from(roleSites) .innerJoin(roles, eq(roleSites.roleId, roles.roleId)) .where(eq(roleSites.siteId, siteId)); return response(res, { data: siteRolesList, success: true, error: false, message: "Site roles retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/site/listSites.ts ================================================ import { db, exitNodes, newts, orgs, remoteExitNodes, roleSites, sites, userSites } from "@server/db"; import cache from "#dynamic/lib/cache"; 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, 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"; async function getLatestNewtVersion(): Promise { try { const cachedVersion = await cache.get("latestNewtVersion"); if (cachedVersion) { return cachedVersion; } const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1500); // Reduced timeout to 1.5 seconds const response = await fetch( "https://api.github.com/repos/fosrl/newt/tags", { signal: controller.signal } ); clearTimeout(timeoutId); if (!response.ok) { logger.warn( `Failed to fetch latest Newt 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 Newt repository"); return null; } tags = tags.filter((version) => !version.name.includes("rc")); const latestVersion = tags[0].name; await cache.set("latestNewtVersion", latestVersion); return latestVersion; } catch (error: any) { if (error.name === "AbortError") { logger.warn( "Request to fetch latest Newt version timed out (1.5s)" ); } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { logger.warn( "Connection timeout while fetching latest Newt version" ); } else { logger.warn( "Error fetching latest Newt version:", error.message || error ); } return null; } } const listSitesParamsSchema = z.strictObject({ orgId: z.string() }); const listSitesSchema = z.object({ pageSize: z.coerce .number() // 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() // 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(["name", "megabytesIn", "megabytesOut"]) .optional() .catch(undefined) .openapi({ type: "string", enum: ["name", "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 querySitesBase() { return db .select({ siteId: sites.siteId, niceId: sites.niceId, name: sites.name, pubKey: sites.pubKey, subnet: sites.subnet, megabytesIn: sites.megabytesIn, megabytesOut: sites.megabytesOut, orgName: orgs.name, type: sites.type, online: sites.online, address: sites.address, newtVersion: newts.version, exitNodeId: sites.exitNodeId, exitNodeName: exitNodes.name, exitNodeEndpoint: exitNodes.endpoint, remoteExitNodeId: remoteExitNodes.remoteExitNodeId }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) .leftJoin(newts, eq(newts.siteId, sites.siteId)) .leftJoin(exitNodes, eq(exitNodes.exitNodeId, sites.exitNodeId)) .leftJoin( remoteExitNodes, eq(remoteExitNodes.exitNodeId, sites.exitNodeId) ); } type SiteWithUpdateAvailable = Awaited>[0] & { newtUpdateAvailable?: boolean; }; export type ListSitesResponse = PaginatedResponse<{ sites: SiteWithUpdateAvailable[]; }>; registry.registerPath({ method: "get", path: "/org/{orgId}/sites", description: "List all sites in an organization", tags: [OpenAPITags.Site], request: { params: listSitesParamsSchema, query: listSitesSchema }, responses: {} }); export async function listSites( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listSitesSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const parsedParams = listSitesParamsSchema.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 accessibleSites; if (req.user) { accessibleSites = await db .select({ siteId: sql`COALESCE(${userSites.siteId}, ${roleSites.siteId})` }) .from(userSites) .fullJoin(roleSites, eq(userSites.siteId, roleSites.siteId)) .where( or( eq(userSites.userId, req.user!.userId), eq(roleSites.roleId, req.userOrgRoleId!) ) ); } else { accessibleSites = await db .select({ siteId: sites.siteId }) .from(sites) .where(eq(sites.orgId, orgId)); } const { pageSize, page, query, sort_by, order, online } = parsedQuery.data; 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 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)).as("filtered_sites") ); const siteListQuery = baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) .orderBy( sort_by ? order === "asc" ? asc(sites[sort_by]) : desc(sites[sort_by]) : asc(sites.name) ); const [totalCount, rows] = await Promise.all([ countQuery, siteListQuery ]); // Get latest version asynchronously without blocking the response const latestNewtVersionPromise = getLatestNewtVersion(); 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 { const latestNewtVersion = await latestNewtVersionPromise; if (latestNewtVersion) { sitesWithUpdates.forEach((site) => { if ( site.type === "newt" && site.newtVersion && latestNewtVersion ) { try { site.newtUpdateAvailable = semver.lt( site.newtVersion, latestNewtVersion ); } catch (error) { site.newtUpdateAvailable = false; } } }); } } catch (error) { // Log the error but don't let it block the response logger.warn( "Failed to check for Newt updates, continuing without update info:", error ); } return response(res, { data: { sites: sitesWithUpdates, pagination: { total: totalCount, pageSize, page } }, success: true, error: false, message: "Sites retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/site/pickSiteDefaults.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { exitNodes, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { findNextAvailableCidr, getNextAvailableClientSubnet } from "@server/lib/ip"; import { generateId } from "@server/auth/sessions/app"; import config from "@server/lib/config"; import { OpenAPITags, registry } from "@server/openApi"; import { fromError } from "zod-validation-error"; import { z } from "zod"; import { listExitNodes } from "#dynamic/lib/exitNodes"; export type PickSiteDefaultsResponse = { exitNodeId: number; address: string; publicKey: string; name: string; listenPort: number; endpoint: string; subnet: string; // TODO: make optional? newtId: string; newtSecret: string; clientAddress?: string; }; registry.registerPath({ method: "get", path: "/org/{orgId}/pick-site-defaults", description: "Return pre-requisite data for creating a site, such as the exit node, subnet, Newt credentials, etc.", tags: [OpenAPITags.Site], request: { params: z.object({ orgId: z.string() }) }, responses: {} }); const pickSiteDefaultsSchema = z.strictObject({ orgId: z.string() }); export async function pickSiteDefaults( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = pickSiteDefaultsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; // TODO: more intelligent way to pick the exit node const exitNodesList = await listExitNodes(orgId); const randomExitNode = exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; if (!randomExitNode) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "No available exit node" ) ); } // TODO: this probably can be optimized... // list all of the sites on that exit node const sitesQuery = await db .select({ subnet: sites.subnet }) .from(sites) .where(eq(sites.exitNodeId, randomExitNode.exitNodeId)); // TODO: we need to lock this subnet for some time so someone else does not take it const subnets = sitesQuery .map((site) => site.subnet) .filter( (subnet) => subnet && /^(\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(subnet) ) .filter((subnet) => subnet !== null); // exclude the exit node address by replacing after the / with a site block size subnets.push( randomExitNode.address.replace( /\/\d+$/, `/${config.getRawConfig().gerbil.site_block_size}` ) ); const newSubnet = findNextAvailableCidr( subnets, config.getRawConfig().gerbil.site_block_size, randomExitNode.address ); if (!newSubnet) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "No available subnets" ) ); } const newClientAddress = await getNextAvailableClientSubnet(orgId); if (!newClientAddress) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "No available subnet found" ) ); } const clientAddress = newClientAddress.split("/")[0]; const newtId = generateId(15); const secret = generateId(48); return response(res, { data: { exitNodeId: randomExitNode.exitNodeId, address: randomExitNode.address, publicKey: randomExitNode.publicKey, name: randomExitNode.name, listenPort: randomExitNode.listenPort, endpoint: randomExitNode.endpoint, // subnet: `${newSubnet.split("/")[0]}/${config.getRawConfig().gerbil.block_size}`, // we want the block size of the whole subnet subnet: newSubnet, clientAddress: clientAddress, newtId, newtSecret: secret }, success: true, error: false, message: "Site defaults chosen successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/site/socketIntegration.ts ================================================ import { db } from "@server/db"; import { newts, sites } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } 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 stoi from "@server/lib/stoi"; import { sendToClient } from "#dynamic/routers/ws"; import { fetchContainers, dockerSocket } from "../newt/dockerSocket"; import cache from "#dynamic/lib/cache"; export interface ContainerNetwork { networkId: string; endpointId: string; gateway?: string; ipAddress?: string; ipPrefixLen?: number; macAddress?: string; aliases?: string[]; dnsNames?: string[]; } export interface ContainerPort { privatePort: number; publicPort?: number; type: "tcp" | "udp"; ip?: string; } export interface Container { id: string; name: string; image: string; state: "running" | "exited" | "paused" | "created"; status: string; ports?: ContainerPort[]; labels: Record; created: number; networks: Record; } const siteIdParamsSchema = z.strictObject({ siteId: z.string().transform(stoi).pipe(z.int().positive()) }); const DockerStatusSchema = z.strictObject({ isAvailable: z.boolean(), socketPath: z.string().optional() }); function validateSiteIdParams(params: any) { const parsedParams = siteIdParamsSchema.safeParse(params); if (!parsedParams.success) { throw createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ); } return parsedParams.data; } async function getSiteAndValidateNewt(siteId: number) { const [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { throw createHttpError(HttpCode.NOT_FOUND, "Site not found"); } if (site.type !== "newt") { throw createHttpError( HttpCode.BAD_REQUEST, "This endpoint is only for Newt sites" ); } return site; } async function getNewtBySiteId(siteId: number) { const [newt] = await db .select() .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); if (!newt) { throw createHttpError(HttpCode.NOT_FOUND, "Newt not found for site"); } return newt; } async function getSiteAndNewt(siteId: number) { const site = await getSiteAndValidateNewt(siteId); const newt = await getNewtBySiteId(siteId); return { site, newt }; } function asyncHandler( operation: (siteId: number) => Promise, successMessage: string ) { return async ( req: Request, res: Response, next: NextFunction ): Promise => { try { const { siteId } = validateSiteIdParams(req.params); const result = await operation(siteId); return response(res, { data: result, success: true, error: false, message: successMessage, status: HttpCode.OK }); } catch (error) { if (createHttpError.isHttpError(error)) { return next(error); } logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred" ) ); } }; } // Core business logic functions async function triggerFetch(siteId: number) { const { newt } = await getSiteAndNewt(siteId); logger.info( `Triggering fetch containers for site ${siteId} with Newt ${newt.newtId}` ); fetchContainers(newt.newtId); // clear the cache for this Newt ID so that the site has to keep asking for the containers // this is to ensure that the site always gets the latest data await cache.del(`${newt.newtId}:dockerContainers`); return { siteId, newtId: newt.newtId }; } async function queryContainers(siteId: number) { const { newt } = await getSiteAndNewt(siteId); const result = await cache.get(`${newt.newtId}:dockerContainers`); if (!result) { throw createHttpError( HttpCode.TOO_EARLY, "Nothing found yet. Perhaps the fetch is still in progress? Wait a bit and try again." ); } return result; } async function isDockerAvailable(siteId: number): Promise { const { newt } = await getSiteAndNewt(siteId); const key = `${newt.newtId}:isAvailable`; const isAvailable = await cache.get(key); return !!isAvailable; } async function getDockerStatus( siteId: number ): Promise> { const { newt } = await getSiteAndNewt(siteId); const keys = ["isAvailable", "socketPath"]; const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`); const values = await cache.mget(mappedKeys); const result = { isAvailable: values[0] as boolean, socketPath: values[1] as string | undefined }; return result; } async function checkSocket( siteId: number ): Promise<{ siteId: number; newtId: string }> { const { newt } = await getSiteAndNewt(siteId); logger.info( `Checking Docker socket for site ${siteId} with Newt ${newt.newtId}` ); // Trigger the Docker socket check dockerSocket(newt.newtId); return { siteId, newtId: newt.newtId }; } // Export types export type GetDockerStatusResponse = NonNullable< Awaited> >; export type ListContainersResponse = Awaited< ReturnType >; export type TriggerFetchResponse = Awaited>; // Route handlers export const triggerFetchContainers = asyncHandler( triggerFetch, "Fetch containers triggered successfully" ); export const listContainers = asyncHandler( queryContainers, "Containers retrieved successfully" ); export const dockerOnline = asyncHandler(async (siteId: number) => { const isAvailable = await isDockerAvailable(siteId); return { isAvailable }; }, "Docker availability checked successfully"); export const dockerStatus = asyncHandler( getDockerStatus, "Docker status retrieved successfully" ); export async function checkDockerSocket( req: Request, res: Response, next: NextFunction ): Promise { try { const { siteId } = validateSiteIdParams(req.params); const result = await checkSocket(siteId); // Notify the Newt client about the Docker socket check sendToClient(result.newtId, { type: "newt/socket/check", data: {} }); return response(res, { data: result, success: true, error: false, message: "Docker socket checked successfully", status: HttpCode.OK }); } catch (error) { if (createHttpError.isHttpError(error)) { return next(error); } logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/site/updateSite.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { sites } from "@server/db"; import { eq, and, ne } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { isValidCIDR } from "@server/lib/validators"; const updateSiteParamsSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) }); 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() // subdomain: z // .string() // .min(1) // .max(255) // .transform((val) => val.toLowerCase()) // .optional() // pubKey: z.string().optional(), // subnet: z.string().optional(), // exitNode: z.number().int().positive().optional(), // megabytesIn: z.number().int().nonnegative().optional(), // megabytesOut: z.number().int().nonnegative().optional(), }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" }); registry.registerPath({ method: "post", path: "/site/{siteId}", description: "Update a site.", tags: [OpenAPITags.Site], request: { params: updateSiteParamsSchema, body: { content: { "application/json": { schema: updateSiteBodySchema } } } }, responses: {} }); export async function updateSite( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = updateSiteParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = updateSiteBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { siteId } = parsedParams.data; const updateData = parsedBody.data; // if niceId is provided, check if it's already in use by another site if (updateData.niceId) { const [existingSite] = await db .select() .from(sites) .where( and( eq(sites.niceId, updateData.niceId), eq(sites.orgId, sites.orgId), ne(sites.siteId, siteId) ) ) .limit(1); if (existingSite) { return next( createHttpError( HttpCode.CONFLICT, `A site with niceId "${updateData.niceId}" already exists` ) ); } } // // 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) .set(updateData) .where(eq(sites.siteId, siteId)) .returning(); if (updatedSite.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Site with ID ${siteId} not found` ) ); } return response(res, { data: updatedSite[0], success: true, error: false, message: "Site updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/addClientToSiteResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, siteResources, clients, clientSiteResources } 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, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const addClientToSiteResourceBodySchema = z .object({ clientId: z.number().int().positive() }) .strict(); const addClientToSiteResourceParamsSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/clients/add", description: "Add a single client to a site resource. Clients with a userId cannot be added.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], request: { params: addClientToSiteResourceParamsSchema, body: { content: { "application/json": { schema: addClientToSiteResourceBodySchema } } } }, responses: {} }); export async function addClientToSiteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = addClientToSiteResourceBodySchema.safeParse( req.body ); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { clientId } = parsedBody.data; const parsedParams = addClientToSiteResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; // get the site resource const [siteResource] = await db .select() .from(siteResources) .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!siteResource) { return next( createHttpError(HttpCode.NOT_FOUND, "Site resource not found") ); } // Check if client exists and has a userId const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { return next( createHttpError(HttpCode.NOT_FOUND, "Client not found") ); } if (client.userId !== null) { return next( createHttpError( HttpCode.BAD_REQUEST, "Cannot add clients that are associated with a user" ) ); } // Check if client already exists in site resource const existingEntry = await db .select() .from(clientSiteResources) .where( and( eq(clientSiteResources.siteResourceId, siteResourceId), eq(clientSiteResources.clientId, clientId) ) ); if (existingEntry.length > 0) { return next( createHttpError( HttpCode.CONFLICT, "Client already assigned to site resource" ) ); } await db.transaction(async (trx) => { await trx.insert(clientSiteResources).values({ clientId, siteResourceId }); await rebuildClientAssociationsFromSiteResource(siteResource, trx); }); return response(res, { data: {}, success: true, error: false, message: "Client added to site resource successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/addRoleToSiteResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, siteResources } from "@server/db"; import { roleSiteResources, roles } 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, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const addRoleToSiteResourceBodySchema = z .object({ roleId: z.number().int().positive() }) .strict(); const addRoleToSiteResourceParamsSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/roles/add", description: "Add a single role to a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { params: addRoleToSiteResourceParamsSchema, body: { content: { "application/json": { schema: addRoleToSiteResourceBodySchema } } } }, responses: {} }); export async function addRoleToSiteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = addRoleToSiteResourceBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { roleId } = parsedBody.data; const parsedParams = addRoleToSiteResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; // get the site resource const [siteResource] = await db .select() .from(siteResources) .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!siteResource) { return next( createHttpError(HttpCode.NOT_FOUND, "Site resource not found") ); } // verify the role exists and belongs to the same org const [role] = await db .select() .from(roles) .where( and( eq(roles.roleId, roleId), eq(roles.orgId, siteResource.orgId) ) ) .limit(1); if (!role) { return next( createHttpError( HttpCode.NOT_FOUND, "Role not found or does not belong to the same organization" ) ); } // Check if the role is an admin role if (role.isAdmin) { return next( createHttpError( HttpCode.BAD_REQUEST, "Admin role cannot be assigned to site resources" ) ); } // Check if role already exists in site resource const existingEntry = await db .select() .from(roleSiteResources) .where( and( eq(roleSiteResources.siteResourceId, siteResourceId), eq(roleSiteResources.roleId, roleId) ) ); if (existingEntry.length > 0) { return next( createHttpError( HttpCode.CONFLICT, "Role already assigned to site resource" ) ); } await db.transaction(async (trx) => { await trx.insert(roleSiteResources).values({ roleId, siteResourceId }); await rebuildClientAssociationsFromSiteResource(siteResource, trx); }); return response(res, { data: {}, success: true, error: false, message: "Role added to site resource successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/addUserToSiteResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, siteResources } from "@server/db"; import { userSiteResources } 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, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const addUserToSiteResourceBodySchema = z .object({ userId: z.string() }) .strict(); const addUserToSiteResourceParamsSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/users/add", description: "Add a single user to a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { params: addUserToSiteResourceParamsSchema, body: { content: { "application/json": { schema: addUserToSiteResourceBodySchema } } } }, responses: {} }); export async function addUserToSiteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = addUserToSiteResourceBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { userId } = parsedBody.data; const parsedParams = addUserToSiteResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; // get the site resource const [siteResource] = await db .select() .from(siteResources) .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!siteResource) { return next( createHttpError(HttpCode.NOT_FOUND, "Site resource not found") ); } // Check if user already exists in site resource const existingEntry = await db .select() .from(userSiteResources) .where( and( eq(userSiteResources.siteResourceId, siteResourceId), eq(userSiteResources.userId, userId) ) ); if (existingEntry.length > 0) { return next( createHttpError( HttpCode.CONFLICT, "User already assigned to site resource" ) ); } await db.transaction(async (trx) => { await trx.insert(userSiteResources).values({ userId, siteResourceId }); await rebuildClientAssociationsFromSiteResource(siteResource, trx); }); return response(res, { data: {}, success: true, error: false, message: "User added to site resource successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/batchAddClientToSiteResources.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, clients, clientSiteResources, siteResources, apiKeyOrg } 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, and, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient, rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const batchAddClientToSiteResourcesParamsSchema = z .object({ clientId: z.string().transform(Number).pipe(z.number().int().positive()) }) .strict(); const batchAddClientToSiteResourcesBodySchema = z .object({ siteResourceIds: z .array(z.number().int().positive()) .min(1, "At least one siteResourceId is required") }) .strict(); registry.registerPath({ method: "post", path: "/client/{clientId}/site-resources", description: "Add a machine client to multiple site resources at once.", tags: [OpenAPITags.Client], request: { params: batchAddClientToSiteResourcesParamsSchema, body: { content: { "application/json": { schema: batchAddClientToSiteResourcesBodySchema } } } }, responses: {} }); export async function batchAddClientToSiteResources( req: Request, res: Response, next: NextFunction ): Promise { try { const apiKey = req.apiKey; if (!apiKey) { return next( createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") ); } const parsedParams = batchAddClientToSiteResourcesParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = batchAddClientToSiteResourcesBodySchema.safeParse( req.body ); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { clientId } = parsedParams.data; const { siteResourceIds } = parsedBody.data; const uniqueSiteResourceIds = [...new Set(siteResourceIds)]; const batchSiteResources = await db .select() .from(siteResources) .where( inArray(siteResources.siteResourceId, uniqueSiteResourceIds) ); if (batchSiteResources.length !== uniqueSiteResourceIds.length) { return next( createHttpError( HttpCode.NOT_FOUND, "One or more site resources not found" ) ); } if (!apiKey.isRoot) { const orgIds = [ ...new Set(batchSiteResources.map((sr) => sr.orgId)) ]; if (orgIds.length > 1) { return next( createHttpError( HttpCode.BAD_REQUEST, "All site resources must belong to the same organization" ) ); } const orgId = orgIds[0]; const [apiKeyOrgRow] = await db .select() .from(apiKeyOrg) .where( and( eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), eq(apiKeyOrg.orgId, orgId) ) ) .limit(1); if (!apiKeyOrgRow) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to the organization of the specified site resources" ) ); } const [clientInOrg] = await db .select() .from(clients) .where( and( eq(clients.clientId, clientId), eq(clients.orgId, orgId) ) ) .limit(1); if (!clientInOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "Key does not have access to the specified client" ) ); } } const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { return next( createHttpError(HttpCode.NOT_FOUND, "Client not found") ); } if (client.userId !== null) { return next( createHttpError( HttpCode.BAD_REQUEST, "This endpoint only supports machine (non-user) clients; the specified client is associated with a user" ) ); } const existingEntries = await db .select({ siteResourceId: clientSiteResources.siteResourceId }) .from(clientSiteResources) .where( and( eq(clientSiteResources.clientId, clientId), inArray( clientSiteResources.siteResourceId, batchSiteResources.map((sr) => sr.siteResourceId) ) ) ); const existingSiteResourceIds = new Set( existingEntries.map((e) => e.siteResourceId) ); const siteResourcesToAdd = batchSiteResources.filter( (sr) => !existingSiteResourceIds.has(sr.siteResourceId) ); if (siteResourcesToAdd.length === 0) { return next( createHttpError( HttpCode.CONFLICT, "Client is already assigned to all specified site resources" ) ); } await db.transaction(async (trx) => { for (const siteResource of siteResourcesToAdd) { await trx.insert(clientSiteResources).values({ clientId, siteResourceId: siteResource.siteResourceId }); } await rebuildClientAssociationsFromClient(client, trx); }); return response(res, { data: { addedCount: siteResourcesToAdd.length, skippedCount: batchSiteResources.length - siteResourcesToAdd.length, siteResourceIds: siteResourcesToAdd.map( (sr) => sr.siteResourceId ) }, success: true, error: false, message: `Client added to ${siteResourcesToAdd.length} site resource(s) successfully`, status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/createSiteResource.ts ================================================ import { clientSiteResources, db, newts, orgs, roles, roleSiteResources, SiteResource, siteResources, sites, userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; import { getNextAvailableAliasAddress, 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"; import { OpenAPITags, registry } from "@server/openApi"; import HttpCode from "@server/types/HttpCode"; import { and, eq } 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 createSiteResourceParamsSchema = z.strictObject({ orgId: z.string() }); const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), mode: z.enum(["host", "cidr", "port"]), siteId: z.int(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(), destination: z.string().min(1), enabled: z.boolean().default(true), alias: z .string() .regex( /^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, "Alias must be a fully qualified domain name with optional wildcards (e.g., example.com, *.example.com, host-0?.example.internal)" ) .optional(), userIds: z.array(z.string()), roleIds: z.array(z.int()), clientIds: z.array(z.int()), tcpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().optional(), authDaemonPort: z.int().positive().optional(), authDaemonMode: z.enum(["site", "remote"]).optional() }) .strict() .refine( (data) => { if (data.mode === "host") { // Check if it's a valid IP address using zod (v4 or v6) const isValidIP = z // .union([z.ipv4(), z.ipv6()]) .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere .safeParse(data.destination).success; if (isValidIP) { return true; } // Check if it's a valid domain (hostname pattern, TLD not required) const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; const isValidDomain = domainRegex.test(data.destination); const isValidAlias = data.alias !== undefined && data.alias !== null && data.alias.trim() !== ""; return isValidDomain && isValidAlias; // require the alias to be set in the case of domain } return true; }, { message: "Destination must be a valid IP address or valid domain AND alias is required" } ) .refine( (data) => { if (data.mode === "cidr") { // Check if it's a valid CIDR (v4 or v6) const isValidCIDR = z .union([z.cidrv4(), z.cidrv6()]) .safeParse(data.destination).success; return isValidCIDR; } return true; }, { message: "Destination must be a valid CIDR notation for cidr mode" } ); export type CreateSiteResourceBody = z.infer; export type CreateSiteResourceResponse = SiteResource; registry.registerPath({ method: "put", path: "/org/{orgId}/site-resource", description: "Create a new site resource.", tags: [OpenAPITags.PrivateResource], request: { params: createSiteResourceParamsSchema, body: { content: { "application/json": { schema: createSiteResourceSchema } } } }, responses: {} }); export async function createSiteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = createSiteResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = createSiteResourceSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { orgId } = parsedParams.data; const { name, siteId, mode, // protocol, // proxyPort, // destinationPort, destination, enabled, alias, userIds, roleIds, clientIds, tcpPortRangeString, udpPortRangeString, disableIcmp, authDaemonPort, authDaemonMode } = parsedBody.data; // Verify the site exists and belongs to the org const [site] = await db .select() .from(sites) .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .limit(1); if (!site) { return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } 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") ); } if (!org.subnet || !org.utilitySubnet) { return next( createHttpError( HttpCode.BAD_REQUEST, `Organization with ID ${orgId} has no subnet or utilitySubnet defined defined` ) ); } // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) .safeParse(destination).success; if ( isIp && (isIpInCidr(destination, org.subnet) || isIpInCidr(destination, org.utilitySubnet)) ) { return next( createHttpError( HttpCode.BAD_REQUEST, "IP can not be in the CIDR range of the organization's subnet or utility subnet" ) ); } // // check if resource with same protocol and proxy port already exists (only for port mode) // if (mode === "port" && protocol && proxyPort) { // const [existingResource] = await db // .select() // .from(siteResources) // .where( // and( // eq(siteResources.siteId, siteId), // eq(siteResources.orgId, orgId), // eq(siteResources.protocol, protocol), // eq(siteResources.proxyPort, proxyPort) // ) // ) // .limit(1); // if (existingResource && existingResource.siteResourceId) { // return next( // createHttpError( // HttpCode.CONFLICT, // "A resource with the same protocol and proxy port already exists" // ) // ); // } // } // make sure the alias is unique within the org if provided if (alias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, orgId), eq(siteResources.alias, alias.trim()) ) ) .limit(1); if (conflict) { return next( createHttpError( HttpCode.CONFLICT, "Alias already in use by another site resource" ) ); } } const isLicensedSshPam = await isLicensedOrSubscribed( orgId, tierMatrix.sshPam ); const niceId = await getUniqueSiteResourceName(orgId); let aliasAddress: string | null = null; if (mode == "host") { // we can only have an alias on a host aliasAddress = await getNextAvailableAliasAddress(orgId); } 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(insertValues) .returning(); const siteResourceId = newSiteResource.siteResourceId; //////////////////// update the associations //////////////////// const [adminRole] = await trx .select() .from(roles) .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) .limit(1); if (!adminRole) { return next( createHttpError(HttpCode.NOT_FOUND, `Admin role not found`) ); } await trx.insert(roleSiteResources).values({ roleId: adminRole.roleId, siteResourceId: siteResourceId }); if (roleIds.length > 0) { await trx .insert(roleSiteResources) .values( roleIds.map((roleId) => ({ roleId, siteResourceId })) ); } if (userIds.length > 0) { await trx .insert(userSiteResources) .values( userIds.map((userId) => ({ userId, siteResourceId })) ); } if (clientIds.length > 0) { await trx.insert(clientSiteResources).values( clientIds.map((clientId) => ({ clientId, siteResourceId })) ); } const [newt] = await trx .select() .from(newts) .where(eq(newts.siteId, site.siteId)) .limit(1); if (!newt) { return next( createHttpError(HttpCode.NOT_FOUND, "Newt not found") ); } await rebuildClientAssociationsFromSiteResource( newSiteResource, trx ); // we need to call this because we added to the admin role }); if (!newSiteResource) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Site resource creation failed" ) ); } logger.info( `Created site resource ${newSiteResource.siteResourceId} for site ${siteId}` ); return response(res, { data: newSiteResource, success: true, error: false, message: "Site resource created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error("Error creating site resource:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to create site resource" ) ); } } ================================================ FILE: server/routers/siteResource/deleteSiteResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, newts, sites } from "@server/db"; import { siteResources } 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 { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const deleteSiteResourceParamsSchema = z.strictObject({ siteResourceId: z.string().transform(Number).pipe(z.int().positive()) }); export type DeleteSiteResourceResponse = { message: string; }; registry.registerPath({ method: "delete", path: "/site-resource/{siteResourceId}", description: "Delete a site resource.", tags: [OpenAPITags.PrivateResource], request: { params: deleteSiteResourceParamsSchema }, responses: {} }); export async function deleteSiteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = deleteSiteResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; // Check if site resource exists const [existingSiteResource] = await db .select() .from(siteResources) .where(and(eq(siteResources.siteResourceId, siteResourceId))) .limit(1); if (!existingSiteResource) { return next( createHttpError(HttpCode.NOT_FOUND, "Site resource not found") ); } await db.transaction(async (trx) => { // Delete the site resource const [removedSiteResource] = await trx .delete(siteResources) .where(and(eq(siteResources.siteResourceId, siteResourceId))) .returning(); const [newt] = await trx .select() .from(newts) .where(eq(newts.siteId, removedSiteResource.siteId)) .limit(1); if (!newt) { return next( createHttpError(HttpCode.NOT_FOUND, "Newt not found") ); } await rebuildClientAssociationsFromSiteResource( removedSiteResource, trx ); }); logger.info(`Deleted site resource ${siteResourceId}`); return response(res, { data: { message: "Site resource deleted successfully" }, success: true, error: false, message: "Site resource deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error("Error deleting site resource:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to delete site resource" ) ); } } ================================================ FILE: server/routers/siteResource/getSiteResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { siteResources, SiteResource } 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"; const getSiteResourceParamsSchema = z.strictObject({ siteResourceId: z .string() .optional() .transform((val) => (val ? Number(val) : undefined)) .pipe(z.int().positive().optional()) .optional(), siteId: z.string().transform(Number).pipe(z.int().positive()), niceId: z.string().optional(), orgId: z.string() }); async function query( siteResourceId?: number, siteId?: number, niceId?: string, orgId?: string ) { if (siteResourceId && siteId && orgId) { const [siteResource] = await db .select() .from(siteResources) .where( and( eq(siteResources.siteResourceId, siteResourceId), eq(siteResources.siteId, siteId), eq(siteResources.orgId, orgId) ) ) .limit(1); return siteResource; } else if (niceId && siteId && orgId) { const [siteResource] = await db .select() .from(siteResources) .where( and( eq(siteResources.niceId, niceId), eq(siteResources.siteId, siteId), eq(siteResources.orgId, orgId) ) ) .limit(1); return siteResource; } } export type GetSiteResourceResponse = NonNullable< Awaited> >; registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}", description: "Get a specific site resource by siteResourceId.", tags: [OpenAPITags.PrivateResource], request: { params: z.object({ siteResourceId: z.number(), siteId: z.number(), orgId: z.string() }) }, responses: {} }); registry.registerPath({ method: "get", path: "/org/{orgId}/site/{siteId}/resource/nice/{niceId}", description: "Get a specific site resource by niceId.", tags: [OpenAPITags.PrivateResource], request: { params: z.object({ niceId: z.string(), siteId: z.number(), orgId: z.string() }) }, responses: {} }); export async function getSiteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getSiteResourceParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId, siteId, niceId, orgId } = parsedParams.data; // Get the site resource const siteResource = await query(siteResourceId, siteId, niceId, orgId); if (!siteResource) { return next( createHttpError(HttpCode.NOT_FOUND, "Site resource not found") ); } return response(res, { data: siteResource, success: true, error: false, message: "Site resource retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error("Error getting site resource:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to get site resource" ) ); } } ================================================ FILE: server/routers/siteResource/index.ts ================================================ export * from "./createSiteResource"; export * from "./deleteSiteResource"; export * from "./getSiteResource"; export * from "./updateSiteResource"; export * from "./listSiteResources"; export * from "./listAllSiteResourcesByOrg"; export * from "./listSiteResourceRoles"; export * from "./listSiteResourceUsers"; export * from "./listSiteResourceClients"; export * from "./setSiteResourceRoles"; export * from "./setSiteResourceUsers"; export * from "./addRoleToSiteResource"; export * from "./removeRoleFromSiteResource"; export * from "./addUserToSiteResource"; export * from "./removeUserFromSiteResource"; export * from "./setSiteResourceClients"; export * from "./addClientToSiteResource"; export * from "./batchAddClientToSiteResources"; export * from "./removeClientFromSiteResource"; ================================================ FILE: server/routers/siteResource/listAllSiteResourcesByOrg.ts ================================================ import { db, SiteResource, siteResources, sites } 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 type { PaginatedResponse } from "@server/types/Pagination"; import { and, asc, desc, 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({ pageSize: z.coerce .number() // 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() // 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(), mode: z .enum(["host", "cidr"]) .optional() .catch(undefined) .openapi({ type: "string", enum: ["host", "cidr"], description: "Filter site resources by mode" }), sort_by: z .enum(["name"]) .optional() .catch(undefined) .openapi({ type: "string", enum: ["name"], description: "Field to sort by" }), order: z .enum(["asc", "desc"]) .optional() .default("asc") .catch("asc") .openapi({ type: "string", enum: ["asc", "desc"], default: "asc", description: "Sort order" }) }); 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", path: "/org/{orgId}/site-resources", description: "List all site resources for an organization.", tags: [OpenAPITags.PrivateResource], request: { params: listAllSiteResourcesByOrgParamsSchema, query: listAllSiteResourcesByOrgQuerySchema }, responses: {} }); export async function listAllSiteResourcesByOrg( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listAllSiteResourcesByOrgParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedQuery = listAllSiteResourcesByOrgQuerySchema.safeParse( req.query ); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { orgId } = parsedParams.data; const { page, pageSize, query, mode, sort_by, order } = parsedQuery.data; 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() + "%" ) ) ); } if (mode) { conditions.push(eq(siteResources.mode, mode)); } const baseQuery = querySiteResourcesBase().where(and(...conditions)); const countQuery = db.$count( querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources") ); const [siteResourcesList, totalCount] = await Promise.all([ baseQuery .limit(pageSize) .offset(pageSize * (page - 1)) .orderBy( sort_by ? order === "asc" ? asc(siteResources[sort_by]) : desc(siteResources[sort_by]) : asc(siteResources.name) ), countQuery ]); return response(res, { data: { siteResources: siteResourcesList, pagination: { total: totalCount, pageSize, page } }, success: true, error: false, message: "Site resources retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error("Error listing all site resources by org:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to list site resources" ) ); } } ================================================ FILE: server/routers/siteResource/listSiteResourceClients.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { clientSiteResources, clients } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const listSiteResourceClientsSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); async function queryClients(siteResourceId: number) { return await db .select({ clientId: clientSiteResources.clientId, name: clients.name, subnet: clients.subnet }) .from(clientSiteResources) .innerJoin(clients, eq(clientSiteResources.clientId, clients.clientId)) .where(eq(clientSiteResources.siteResourceId, siteResourceId)); } export type ListSiteResourceClientsResponse = { clients: NonNullable>>; }; registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/clients", description: "List all clients for a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], request: { params: listSiteResourceClientsSchema }, responses: {} }); export async function listSiteResourceClients( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listSiteResourceClientsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; const siteResourceClientsList = await queryClients(siteResourceId); return response(res, { data: { clients: siteResourceClientsList }, success: true, error: false, message: "Site resource clients retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/listSiteResourceRoles.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roleSiteResources, roles } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const listSiteResourceRolesSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); async function query(siteResourceId: number) { return await db .select({ roleId: roles.roleId, name: roles.name, description: roles.description, isAdmin: roles.isAdmin }) .from(roleSiteResources) .innerJoin(roles, eq(roleSiteResources.roleId, roles.roleId)) .where(eq(roleSiteResources.siteResourceId, siteResourceId)); } export type ListSiteResourceRolesResponse = { roles: NonNullable>>; }; registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/roles", description: "List all roles for a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { params: listSiteResourceRolesSchema }, responses: {} }); export async function listSiteResourceRoles( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listSiteResourceRolesSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; const siteResourceRolesList = await query(siteResourceId); return response(res, { data: { roles: siteResourceRolesList }, success: true, error: false, message: "Site resource roles retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/listSiteResourceUsers.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { idp, userSiteResources, users } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const listSiteResourceUsersSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); async function queryUsers(siteResourceId: number) { return await db .select({ userId: userSiteResources.userId, username: users.username, type: users.type, idpName: idp.name, idpId: users.idpId, email: users.email }) .from(userSiteResources) .innerJoin(users, eq(userSiteResources.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .where(eq(userSiteResources.siteResourceId, siteResourceId)); } export type ListSiteResourceUsersResponse = { users: NonNullable>>; }; registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/users", description: "List all users for a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { params: listSiteResourceUsersSchema }, responses: {} }); export async function listSiteResourceUsers( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listSiteResourceUsersSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; const siteResourceUsersList = await queryUsers(siteResourceId); return response(res, { data: { users: siteResourceUsersList }, success: true, error: false, message: "Site resource users retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/listSiteResources.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { siteResources, sites, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { and, asc, desc, eq } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; const listSiteResourcesParamsSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()), orgId: z.string() }); const listSiteResourcesQuerySchema = z.object({ limit: z .string() .optional() .default("100") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()), sort_by: z .enum(["name"]) .optional() .catch(undefined) .openapi({ type: "string", enum: ["name"], description: "Field to sort by" }), order: z .enum(["asc", "desc"]) .optional() .default("asc") .catch("asc") .openapi({ type: "string", enum: ["asc", "desc"], default: "asc", description: "Sort order" }) }); export type ListSiteResourcesResponse = { siteResources: SiteResource[]; }; registry.registerPath({ method: "get", path: "/org/{orgId}/site/{siteId}/resources", description: "List site resources for a site.", tags: [OpenAPITags.PrivateResource], request: { params: listSiteResourcesParamsSchema, query: listSiteResourcesQuerySchema }, responses: {} }); export async function listSiteResources( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = listSiteResourcesParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedQuery = listSiteResourcesQuerySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { siteId, orgId } = parsedParams.data; const { limit, offset, sort_by, order } = parsedQuery.data; // Verify the site exists and belongs to the org const site = await db .select() .from(sites) .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) .limit(1); if (site.length === 0) { return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } // Get site resources const siteResourcesList = await db .select() .from(siteResources) .where( and( eq(siteResources.siteId, siteId), eq(siteResources.orgId, orgId) ) ) .orderBy( sort_by ? order === "asc" ? asc(siteResources[sort_by]) : desc(siteResources[sort_by]) : asc(siteResources.name) ) .limit(limit) .offset(offset); return response(res, { data: { siteResources: siteResourcesList }, success: true, error: false, message: "Site resources retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error("Error listing site resources:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to list site resources" ) ); } } ================================================ FILE: server/routers/siteResource/removeClientFromSiteResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, siteResources, clients, clientSiteResources } 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, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const removeClientFromSiteResourceBodySchema = z .object({ clientId: z.number().int().positive() }) .strict(); const removeClientFromSiteResourceParamsSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/clients/remove", description: "Remove a single client from a site resource. Clients with a userId cannot be removed.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], request: { params: removeClientFromSiteResourceParamsSchema, body: { content: { "application/json": { schema: removeClientFromSiteResourceBodySchema } } } }, responses: {} }); export async function removeClientFromSiteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = removeClientFromSiteResourceBodySchema.safeParse( req.body ); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { clientId } = parsedBody.data; const parsedParams = removeClientFromSiteResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; // get the site resource const [siteResource] = await db .select() .from(siteResources) .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!siteResource) { return next( createHttpError(HttpCode.NOT_FOUND, "Site resource not found") ); } // Check if client exists and has a userId const [client] = await db .select() .from(clients) .where(eq(clients.clientId, clientId)) .limit(1); if (!client) { return next( createHttpError(HttpCode.NOT_FOUND, "Client not found") ); } if (client.userId !== null) { return next( createHttpError( HttpCode.BAD_REQUEST, "Cannot remove clients that are associated with a user" ) ); } // Check if client exists in site resource const existingEntry = await db .select() .from(clientSiteResources) .where( and( eq(clientSiteResources.siteResourceId, siteResourceId), eq(clientSiteResources.clientId, clientId) ) ); if (existingEntry.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, "Client not found in site resource" ) ); } await db.transaction(async (trx) => { await trx .delete(clientSiteResources) .where( and( eq(clientSiteResources.siteResourceId, siteResourceId), eq(clientSiteResources.clientId, clientId) ) ); await rebuildClientAssociationsFromSiteResource(siteResource, trx); }); return response(res, { data: {}, success: true, error: false, message: "Client removed from site resource successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/removeRoleFromSiteResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, siteResources } from "@server/db"; import { roleSiteResources, roles } 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, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const removeRoleFromSiteResourceBodySchema = z .object({ roleId: z.number().int().positive() }) .strict(); const removeRoleFromSiteResourceParamsSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/roles/remove", description: "Remove a single role from a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { params: removeRoleFromSiteResourceParamsSchema, body: { content: { "application/json": { schema: removeRoleFromSiteResourceBodySchema } } } }, responses: {} }); export async function removeRoleFromSiteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = removeRoleFromSiteResourceBodySchema.safeParse( req.body ); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { roleId } = parsedBody.data; const parsedParams = removeRoleFromSiteResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; // get the site resource const [siteResource] = await db .select() .from(siteResources) .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!siteResource) { return next( createHttpError(HttpCode.NOT_FOUND, "Site resource not found") ); } // Check if the role is an admin role const [roleToCheck] = await db .select() .from(roles) .where( and( eq(roles.roleId, roleId), eq(roles.orgId, siteResource.orgId) ) ) .limit(1); if (!roleToCheck) { return next( createHttpError( HttpCode.NOT_FOUND, "Role not found or does not belong to the same organization" ) ); } if (roleToCheck.isAdmin) { return next( createHttpError( HttpCode.BAD_REQUEST, "Admin role cannot be removed from site resources" ) ); } // Check if role exists in site resource const existingEntry = await db .select() .from(roleSiteResources) .where( and( eq(roleSiteResources.siteResourceId, siteResourceId), eq(roleSiteResources.roleId, roleId) ) ); if (existingEntry.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, "Role not found in site resource" ) ); } await db.transaction(async (trx) => { await trx .delete(roleSiteResources) .where( and( eq(roleSiteResources.siteResourceId, siteResourceId), eq(roleSiteResources.roleId, roleId) ) ); await rebuildClientAssociationsFromSiteResource(siteResource, trx); }); return response(res, { data: {}, success: true, error: false, message: "Role removed from site resource successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/removeUserFromSiteResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, siteResources } from "@server/db"; import { userSiteResources } 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, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const removeUserFromSiteResourceBodySchema = z .object({ userId: z.string() }) .strict(); const removeUserFromSiteResourceParamsSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/users/remove", description: "Remove a single user from a site resource.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { params: removeUserFromSiteResourceParamsSchema, body: { content: { "application/json": { schema: removeUserFromSiteResourceBodySchema } } } }, responses: {} }); export async function removeUserFromSiteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = removeUserFromSiteResourceBodySchema.safeParse( req.body ); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { userId } = parsedBody.data; const parsedParams = removeUserFromSiteResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; // get the site resource const [siteResource] = await db .select() .from(siteResources) .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!siteResource) { return next( createHttpError(HttpCode.NOT_FOUND, "Site resource not found") ); } // Check if user exists in site resource const existingEntry = await db .select() .from(userSiteResources) .where( and( eq(userSiteResources.siteResourceId, siteResourceId), eq(userSiteResources.userId, userId) ) ); if (existingEntry.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, "User not found in site resource" ) ); } await db.transaction(async (trx) => { await trx .delete(userSiteResources) .where( and( eq(userSiteResources.siteResourceId, siteResourceId), eq(userSiteResources.userId, userId) ) ); await rebuildClientAssociationsFromSiteResource(siteResource, trx); }); return response(res, { data: {}, success: true, error: false, message: "User removed from site resource successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/setSiteResourceClients.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, siteResources, clients, clientSiteResources } 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, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const setSiteResourceClientsBodySchema = z .object({ clientIds: z.array(z.number().int().positive()) }) .strict(); const setSiteResourceClientsParamsSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/clients", description: "Set clients for a site resource. This will replace all existing clients. Clients with a userId cannot be added.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], request: { params: setSiteResourceClientsParamsSchema, body: { content: { "application/json": { schema: setSiteResourceClientsBodySchema } } } }, responses: {} }); export async function setSiteResourceClients( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = setSiteResourceClientsBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { clientIds } = parsedBody.data; const parsedParams = setSiteResourceClientsParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; // get the site resource const [siteResource] = await db .select() .from(siteResources) .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!siteResource) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Site resource not found" ) ); } // Check if any clients have a userId (associated with a user) if (clientIds.length > 0) { const clientsWithUsers = await db .select() .from(clients) .where(inArray(clients.clientId, clientIds)); const clientsWithUserId = clientsWithUsers.filter( (client) => client.userId !== null ); if (clientsWithUserId.length > 0) { return next( createHttpError( HttpCode.BAD_REQUEST, "Cannot add clients that are associated with a user" ) ); } } await db.transaction(async (trx) => { await trx .delete(clientSiteResources) .where(eq(clientSiteResources.siteResourceId, siteResourceId)); if (clientIds.length > 0) { await trx.insert(clientSiteResources).values( clientIds.map((clientId) => ({ clientId, siteResourceId })) ); } await rebuildClientAssociationsFromSiteResource(siteResource, trx); }); return response(res, { data: {}, success: true, error: false, message: "Clients set for site resource successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/setSiteResourceRoles.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, siteResources } from "@server/db"; import { roleSiteResources, roles } 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, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const setSiteResourceRolesBodySchema = z .object({ roleIds: z.array(z.number().int().positive()) }) .strict(); const setSiteResourceRolesParamsSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/roles", description: "Set roles for a site resource. This will replace all existing roles.", tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { params: setSiteResourceRolesParamsSchema, body: { content: { "application/json": { schema: setSiteResourceRolesBodySchema } } } }, responses: {} }); export async function setSiteResourceRoles( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = setSiteResourceRolesBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { roleIds } = parsedBody.data; const parsedParams = setSiteResourceRolesParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; // get the site resource const [siteResource] = await db .select() .from(siteResources) .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!siteResource) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Site resource not found" ) ); } // Check if any of the roleIds are admin roles const rolesToCheck = await db .select() .from(roles) .where( and( inArray(roles.roleId, roleIds), eq(roles.orgId, siteResource.orgId) ) ); const hasAdminRole = rolesToCheck.some((role) => role.isAdmin); if (hasAdminRole) { return next( createHttpError( HttpCode.BAD_REQUEST, "Admin role cannot be assigned to site resources" ) ); } // Get all admin role IDs for this org to exclude from deletion const adminRoles = await db .select() .from(roles) .where( and( eq(roles.isAdmin, true), eq(roles.orgId, siteResource.orgId) ) ); const adminRoleIds = adminRoles.map((role) => role.roleId); await db.transaction(async (trx) => { if (adminRoleIds.length > 0) { await trx.delete(roleSiteResources).where( and( eq(roleSiteResources.siteResourceId, siteResourceId), ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role ) ); } else { await trx .delete(roleSiteResources) .where( eq(roleSiteResources.siteResourceId, siteResourceId) ); } if (roleIds.length > 0) { await trx .insert(roleSiteResources) .values( roleIds.map((roleId) => ({ roleId, siteResourceId })) ); } await rebuildClientAssociationsFromSiteResource(siteResource, trx); }); return response(res, { data: {}, success: true, error: false, message: "Roles set for site resource successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/setSiteResourceUsers.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, siteResources } from "@server/db"; import { userSiteResources } 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"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const setSiteResourceUsersBodySchema = z .object({ userIds: z.array(z.string()) }) .strict(); const setSiteResourceUsersParamsSchema = z .object({ siteResourceId: z .string() .transform(Number) .pipe(z.number().int().positive()) }) .strict(); registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/users", description: "Set users for a site resource. This will replace all existing users.", tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { params: setSiteResourceUsersParamsSchema, body: { content: { "application/json": { schema: setSiteResourceUsersBodySchema } } } }, responses: {} }); export async function setSiteResourceUsers( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = setSiteResourceUsersBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { userIds } = parsedBody.data; const parsedParams = setSiteResourceUsersParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { siteResourceId } = parsedParams.data; // get the site resource const [siteResource] = await db .select() .from(siteResources) .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!siteResource) { return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Site resource not found" ) ); } await db.transaction(async (trx) => { await trx .delete(userSiteResources) .where(eq(userSiteResources.siteResourceId, siteResourceId)); if (userIds.length > 0) { await trx .insert(userSiteResources) .values( userIds.map((userId) => ({ userId, siteResourceId })) ); } await rebuildClientAssociationsFromSiteResource(siteResource, trx); }); return response(res, { data: {}, success: true, error: false, message: "Users set for site resource successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/siteResource/updateSiteResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { clientSiteResources, clientSiteResourcesAssociationsCache, db, newts, orgs, roles, roleSiteResources, sites, Transaction, userSiteResources } from "@server/db"; import { siteResources, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { eq, and, ne } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import { updatePeerData, updateTargets } from "@server/routers/client/targets"; import { generateAliasConfig, generateRemoteSubnets, generateSubnetProxyTargets, isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; 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()) }); 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(), // proxyPort: z.int().positive().nullish(), // destinationPort: z.int().positive().nullish(), destination: z.string().min(1).optional(), enabled: z.boolean().optional(), alias: z .string() .regex( /^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, "Alias must be a fully qualified domain name with optional wildcards (e.g., example.internal, *.example.internal, host-0?.example.internal)" ) .nullish(), userIds: z.array(z.string()), roleIds: z.array(z.int()), clientIds: z.array(z.int()), tcpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema, disableIcmp: z.boolean().optional(), authDaemonPort: z.int().positive().nullish(), authDaemonMode: z.enum(["site", "remote"]).optional() }) .strict() .refine( (data) => { if (data.mode === "host" && data.destination) { const isValidIP = z // .union([z.ipv4(), z.ipv6()]) .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere .safeParse(data.destination).success; if (isValidIP) { return true; } // Check if it's a valid domain (hostname pattern, TLD not required) const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; const isValidDomain = domainRegex.test(data.destination); const isValidAlias = data.alias !== undefined && data.alias !== null && data.alias.trim() !== ""; return isValidDomain && isValidAlias; // require the alias to be set in the case of domain } return true; }, { message: "Destination must be a valid IP address or valid domain AND alias is required" } ) .refine( (data) => { if (data.mode === "cidr" && data.destination) { // Check if it's a valid CIDR (v4 or v6) const isValidCIDR = z .union([z.cidrv4(), z.cidrv6()]) .safeParse(data.destination).success; return isValidCIDR; } return true; }, { message: "Destination must be a valid CIDR notation for cidr mode" } ); export type UpdateSiteResourceBody = z.infer; export type UpdateSiteResourceResponse = SiteResource; registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}", description: "Update a site resource.", tags: [OpenAPITags.PrivateResource], request: { params: updateSiteResourceParamsSchema, body: { content: { "application/json": { schema: updateSiteResourceSchema } } } }, responses: {} }); export async function updateSiteResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = updateSiteResourceParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = updateSiteResourceSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { siteResourceId } = parsedParams.data; const { name, siteId, // because it can change mode, destination, alias, enabled, userIds, roleIds, clientIds, tcpPortRangeString, udpPortRangeString, disableIcmp, authDaemonPort, authDaemonMode } = parsedBody.data; const [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } // Check if site resource exists const [existingSiteResource] = await db .select() .from(siteResources) .where(and(eq(siteResources.siteResourceId, siteResourceId))) .limit(1); if (!existingSiteResource) { return next( createHttpError(HttpCode.NOT_FOUND, "Site resource not found") ); } const isLicensedSshPam = await isLicensedOrSubscribed( existingSiteResource.orgId, tierMatrix.sshPam ); const [org] = await db .select() .from(orgs) .where(eq(orgs.orgId, existingSiteResource.orgId)) .limit(1); if (!org) { return next( createHttpError(HttpCode.NOT_FOUND, "Organization not found") ); } if (!org.subnet || !org.utilitySubnet) { return next( createHttpError( HttpCode.BAD_REQUEST, `Organization with ID ${existingSiteResource.orgId} has no subnet or utilitySubnet defined defined` ) ); } // Only check if destination is an IP address const isIp = z .union([z.ipv4(), z.ipv6()]) .safeParse(destination).success; if ( isIp && (isIpInCidr(destination!, org.subnet) || isIpInCidr(destination!, org.utilitySubnet)) ) { return next( createHttpError( HttpCode.BAD_REQUEST, "IP can not be in the CIDR range of the organization's subnet or utility subnet" ) ); } let existingSite = site; let siteChanged = false; if (existingSiteResource.siteId !== siteId) { siteChanged = true; // get the existing site [existingSite] = await db .select() .from(sites) .where(eq(sites.siteId, existingSiteResource.siteId)) .limit(1); if (!existingSite) { return next( createHttpError( HttpCode.NOT_FOUND, "Existing site not found" ) ); } } // make sure the alias is unique within the org if provided if (alias) { const [conflict] = await db .select() .from(siteResources) .where( and( eq(siteResources.orgId, existingSiteResource.orgId), eq(siteResources.alias, alias.trim()), ne(siteResources.siteResourceId, siteResourceId) // exclude self ) ) .limit(1); if (conflict) { return next( createHttpError( HttpCode.CONFLICT, "Alias already in use by another site resource" ) ); } } let updatedSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place if (siteChanged) { // delete the existing site resource await trx .delete(siteResources) .where( and(eq(siteResources.siteResourceId, siteResourceId)) ); await rebuildClientAssociationsFromSiteResource( existingSiteResource, trx ); // create the new site resource from the removed one - the ID should stay the same const [insertedSiteResource] = await trx .insert(siteResources) .values({ ...existingSiteResource }) .returning(); // 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({ name: name, siteId: siteId, mode: mode, destination: destination, enabled: enabled, alias: alias && alias.trim() ? alias : null, tcpPortRangeString: tcpPortRangeString, udpPortRangeString: udpPortRangeString, disableIcmp: disableIcmp, ...sshPamSet }) .where( and( eq( siteResources.siteResourceId, insertedSiteResource.siteResourceId ) ) ) .returning(); if (!updatedSiteResource) { throw new Error( "Failed to create updated site resource after site change" ); } //////////////////// update the associations //////////////////// const [adminRole] = await trx .select() .from(roles) .where( and( eq(roles.isAdmin, true), eq(roles.orgId, updatedSiteResource.orgId) ) ) .limit(1); if (!adminRole) { return next( createHttpError( HttpCode.NOT_FOUND, `Admin role not found` ) ); } await trx.insert(roleSiteResources).values({ roleId: adminRole.roleId, siteResourceId: updatedSiteResource.siteResourceId }); if (roleIds.length > 0) { await trx.insert(roleSiteResources).values( roleIds.map((roleId) => ({ roleId, siteResourceId: updatedSiteResource!.siteResourceId })) ); } if (userIds.length > 0) { await trx.insert(userSiteResources).values( userIds.map((userId) => ({ userId, siteResourceId: updatedSiteResource!.siteResourceId })) ); } if (clientIds.length > 0) { await trx.insert(clientSiteResources).values( clientIds.map((clientId) => ({ clientId, siteResourceId: updatedSiteResource!.siteResourceId })) ); } await rebuildClientAssociationsFromSiteResource( updatedSiteResource, trx ); } else { // Update the site resource const sshPamSet = isLicensedSshPam && (authDaemonPort !== undefined || authDaemonMode !== undefined) ? { ...(authDaemonPort !== undefined && { authDaemonPort }), ...(authDaemonMode !== undefined && { authDaemonMode }) } : {}; [updatedSiteResource] = await trx .update(siteResources) .set({ name: name, siteId: siteId, mode: mode, destination: destination, enabled: enabled, alias: alias && alias.trim() ? alias : null, tcpPortRangeString: tcpPortRangeString, udpPortRangeString: udpPortRangeString, disableIcmp: disableIcmp, ...sshPamSet }) .where( and(eq(siteResources.siteResourceId, siteResourceId)) ) .returning(); //////////////////// update the associations //////////////////// await trx .delete(clientSiteResources) .where( eq(clientSiteResources.siteResourceId, siteResourceId) ); if (clientIds.length > 0) { await trx.insert(clientSiteResources).values( clientIds.map((clientId) => ({ clientId, siteResourceId })) ); } await trx .delete(userSiteResources) .where( eq(userSiteResources.siteResourceId, siteResourceId) ); if (userIds.length > 0) { await trx.insert(userSiteResources).values( userIds.map((userId) => ({ userId, siteResourceId })) ); } // Get all admin role IDs for this org to exclude from deletion const adminRoles = await trx .select() .from(roles) .where( and( eq(roles.isAdmin, true), eq(roles.orgId, updatedSiteResource.orgId) ) ); const adminRoleIds = adminRoles.map((role) => role.roleId); if (adminRoleIds.length > 0) { await trx.delete(roleSiteResources).where( and( eq( roleSiteResources.siteResourceId, siteResourceId ), ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role ) ); } else { await trx .delete(roleSiteResources) .where( eq(roleSiteResources.siteResourceId, siteResourceId) ); } if (roleIds.length > 0) { await trx.insert(roleSiteResources).values( roleIds.map((roleId) => ({ roleId, siteResourceId })) ); } logger.info( `Updated site resource ${siteResourceId} for site ${siteId}` ); await handleMessagingForUpdatedSiteResource( existingSiteResource, updatedSiteResource, { siteId: site.siteId, orgId: site.orgId }, trx ); } }); return response(res, { data: updatedSiteResource, success: true, error: false, message: "Site resource updated successfully", status: HttpCode.OK }); } catch (error) { logger.error("Error updating site resource:", error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to update site resource" ) ); } } export async function handleMessagingForUpdatedSiteResource( existingSiteResource: SiteResource | undefined, updatedSiteResource: SiteResource, site: { siteId: number; orgId: string }, trx: Transaction ) { logger.debug( "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", existingSiteResource ); logger.debug( "handleMessagingForUpdatedSiteResource: updatedSiteResource is: ", updatedSiteResource ); const { mergedAllClients } = await rebuildClientAssociationsFromSiteResource( existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below trx ); // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed const destinationChanged = existingSiteResource && existingSiteResource.destination !== updatedSiteResource.destination; const aliasChanged = existingSiteResource && existingSiteResource.alias !== updatedSiteResource.alias; const portRangesChanged = existingSiteResource && (existingSiteResource.tcpPortRangeString !== updatedSiteResource.tcpPortRangeString || existingSiteResource.udpPortRangeString !== updatedSiteResource.udpPortRangeString || existingSiteResource.disableIcmp !== updatedSiteResource.disableIcmp); // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all if (destinationChanged || aliasChanged || portRangesChanged) { const [newt] = await trx .select() .from(newts) .where(eq(newts.siteId, site.siteId)) .limit(1); if (!newt) { throw new Error( "Newt not found for site during site resource update" ); } // Only update targets on newt if destination changed if (destinationChanged || portRangesChanged) { const oldTargets = generateSubnetProxyTargets( existingSiteResource, mergedAllClients ); const newTargets = generateSubnetProxyTargets( updatedSiteResource, mergedAllClients ); await updateTargets(newt.newtId, { oldTargets: oldTargets, newTargets: newTargets }, newt.version); } const olmJobs: Promise[] = []; for (const client of mergedAllClients) { // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet // todo: optimize this query if needed const oldDestinationStillInUseSites = await trx .select() .from(siteResources) .innerJoin( clientSiteResourcesAssociationsCache, eq( clientSiteResourcesAssociationsCache.siteResourceId, siteResources.siteResourceId ) ) .where( and( eq( clientSiteResourcesAssociationsCache.clientId, client.clientId ), eq(siteResources.siteId, site.siteId), eq( siteResources.destination, existingSiteResource.destination ), ne( siteResources.siteResourceId, existingSiteResource.siteResourceId ) ) ); const oldDestinationStillInUseByASite = oldDestinationStillInUseSites.length > 0; // we also need to update the remote subnets on the olms for each client that has access to this site olmJobs.push( updatePeerData( client.clientId, updatedSiteResource.siteId, destinationChanged ? { oldRemoteSubnets: !oldDestinationStillInUseByASite ? generateRemoteSubnets([ existingSiteResource ]) : [], newRemoteSubnets: generateRemoteSubnets([ updatedSiteResource ]) } : undefined, aliasChanged ? { oldAliases: generateAliasConfig([ existingSiteResource ]), newAliases: generateAliasConfig([ updatedSiteResource ]) } : undefined ) ); } await Promise.all(olmJobs); } } ================================================ FILE: server/routers/supporterKey/hideSupporterKey.ts ================================================ import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import config from "@server/lib/config"; export type HideSupporterKeyResponse = { hidden: boolean; }; export async function hideSupporterKey( req: Request, res: Response, next: NextFunction ): Promise { try { config.hideSupporterKey(); return sendResponse(res, { data: { hidden: true }, success: true, error: false, message: "Hidden", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/supporterKey/index.ts ================================================ export * from "./validateSupporterKey"; export * from "./isSupporterKeyVisible"; export * from "./hideSupporterKey"; ================================================ FILE: server/routers/supporterKey/isSupporterKeyVisible.ts ================================================ import { Request, Response, NextFunction } from "express"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { response as sendResponse } from "@server/lib/response"; import config from "@server/lib/config"; import { db } from "@server/db"; import { count } from "drizzle-orm"; import { users } from "@server/db"; import { build } from "@server/build"; export type IsSupporterKeyVisibleResponse = { visible: boolean; tier?: string; }; const USER_LIMIT = 5; export async function isSupporterKeyVisible( req: Request, res: Response, next: NextFunction ): Promise { try { const hidden = config.isSupporterKeyHidden(); const key = config.getSupporterData(); let visible = !hidden && key?.valid !== true; if (key?.tier === "Limited Supporter") { const [numUsers] = await db.select({ count: count() }).from(users); if (numUsers.count > USER_LIMIT) { logger.debug( `User count ${numUsers.count} exceeds limit ${USER_LIMIT}` ); visible = true; } } if (build !== "oss") { visible = false; } return sendResponse(res, { data: { visible, tier: key?.tier || undefined }, success: true, error: false, message: "Status", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/supporterKey/validateSupporterKey.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { response as sendResponse } from "@server/lib/response"; import { supporterKey } from "@server/db"; import { db } from "@server/db"; import config from "@server/lib/config"; const validateSupporterKeySchema = z.strictObject({ githubUsername: z.string().nonempty(), key: z.string().nonempty() }); export type ValidateSupporterKeyResponse = { valid: boolean; githubUsername?: string; tier?: string; phrase?: string; }; export async function validateSupporterKey( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = validateSupporterKeySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { githubUsername, key } = parsedBody.data; const response = await fetch( `https://api.fossorial.io/api/v1/license/validate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ licenseKey: key, githubUsername: githubUsername }) } ); if (!response.ok) { logger.error(response); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "An error occurred" ) ); } const data = await response.json(); if (!data || !data.data.valid) { return sendResponse(res, { data: { valid: false }, success: true, error: false, message: "Invalid supporter key", status: HttpCode.OK }); } await db.transaction(async (trx) => { await trx.delete(supporterKey); await trx.insert(supporterKey).values({ githubUsername: githubUsername, key: key, tier: data.data.tier || null, phrase: data.data.cutePhrase || null, valid: true }); }); await config.checkSupporterKey(); return sendResponse(res, { data: { valid: true, githubUsername: data.data.githubUsername, tier: data.data.tier, phrase: data.data.cutePhrase }, success: true, error: false, message: "Valid supporter key", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/target/createTarget.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, TargetHealthCheck, targetHealthCheck } from "@server/db"; import { newts, resources, sites, Target, targets } 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 { addPeer } from "../gerbil/peers"; import { isIpInCidr } from "@server/lib/ip"; import { fromError } from "zod-validation-error"; import { addTargets } from "../newt/targets"; import { eq } from "drizzle-orm"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; const createTargetParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); const createTargetSchema = z.strictObject({ siteId: z.int().positive(), ip: z.string().refine(isTargetValid), method: z.string().optional().nullable(), port: z.int().min(1).max(65535), enabled: z.boolean().default(true), hcEnabled: z.boolean().optional(), hcPath: z.string().min(1).optional().nullable(), hcScheme: z.string().optional().nullable(), hcMode: z.string().optional().nullable(), hcHostname: z.string().optional().nullable(), hcPort: z.int().positive().optional().nullable(), hcInterval: z.int().positive().min(5).optional().nullable(), hcUnhealthyInterval: z.int().positive().min(5).optional().nullable(), hcTimeout: z.int().positive().min(1).optional().nullable(), hcHeaders: z .array(z.strictObject({ name: z.string(), value: z.string() })) .nullable() .optional(), hcFollowRedirects: z.boolean().optional().nullable(), hcMethod: z.string().min(1).optional().nullable(), hcStatus: z.int().optional().nullable(), hcTlsServerName: z.string().optional().nullable(), path: z.string().optional().nullable(), pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), rewritePath: z.string().optional().nullable(), rewritePathType: z .enum(["exact", "prefix", "regex", "stripPrefix"]) .optional() .nullable(), priority: z.int().min(1).max(1000).optional().nullable() }); export type CreateTargetResponse = Target & TargetHealthCheck; registry.registerPath({ method: "put", path: "/resource/{resourceId}/target", description: "Create a target for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Target], request: { params: createTargetParamsSchema, body: { content: { "application/json": { schema: createTargetSchema } } } }, responses: {} }); export async function createTarget( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = createTargetSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const targetData = parsedBody.data; const parsedParams = createTargetParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { resourceId } = parsedParams.data; // get the resource const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found` ) ); } const siteId = targetData.siteId; const [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { return next( createHttpError( HttpCode.NOT_FOUND, `Site with ID ${siteId} not found` ) ); } const existingTargets = await db .select() .from(targets) .where(eq(targets.resourceId, resourceId)); const existingTarget = existingTargets.find( (target) => target.ip === targetData.ip && target.port === targetData.port && target.method === targetData.method && target.siteId === targetData.siteId ); if (existingTarget) { // log a warning logger.warn( `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${resourceId}` ); } let newTarget: Target[] = []; let healthCheck: TargetHealthCheck[] = []; let targetIps: string[] = []; if (site.type == "local") { newTarget = await db .insert(targets) .values({ resourceId, ...targetData, priority: targetData.priority || 100 }) .returning(); } else { // make sure the target is within the site subnet if ( site.type == "wireguard" && !isIpInCidr(targetData.ip, site.subnet!) ) { return next( createHttpError( HttpCode.BAD_REQUEST, `Target IP is not within the site subnet` ) ); } const { internalPort, targetIps: newTargetIps } = await pickPort( site.siteId!, db ); if (!internalPort) { return next( createHttpError( HttpCode.BAD_REQUEST, `No available internal port` ) ); } newTarget = await db .insert(targets) .values({ resourceId, siteId: site.siteId, ip: targetData.ip, method: targetData.method, port: targetData.port, internalPort, enabled: targetData.enabled, path: targetData.path, pathMatchType: targetData.pathMatchType, rewritePath: targetData.rewritePath, rewritePathType: targetData.rewritePathType, priority: targetData.priority || 100 }) .returning(); // add the new target to the targetIps array newTargetIps.push(`${targetData.ip}/32`); targetIps = newTargetIps; } let hcHeaders = null; if (targetData.hcHeaders) { hcHeaders = JSON.stringify(targetData.hcHeaders); } healthCheck = await db .insert(targetHealthCheck) .values({ targetId: newTarget[0].targetId, hcEnabled: targetData.hcEnabled ?? false, hcPath: targetData.hcPath ?? null, hcScheme: targetData.hcScheme ?? null, hcMode: targetData.hcMode ?? null, hcHostname: targetData.hcHostname ?? null, hcPort: targetData.hcPort ?? null, hcInterval: targetData.hcInterval ?? null, hcUnhealthyInterval: targetData.hcUnhealthyInterval ?? null, hcTimeout: targetData.hcTimeout ?? null, hcHeaders: hcHeaders, hcFollowRedirects: targetData.hcFollowRedirects ?? null, hcMethod: targetData.hcMethod ?? null, hcStatus: targetData.hcStatus ?? null, hcHealth: "unknown", hcTlsServerName: targetData.hcTlsServerName ?? null }) .returning(); if (site.pubKey) { if (site.type == "wireguard") { await addPeer(site.exitNodeId!, { publicKey: site.pubKey, allowedIps: targetIps.flat() }); } else if (site.type == "newt") { // get the newt on the site by querying the newt table for siteId const [newt] = await db .select() .from(newts) .where(eq(newts.siteId, site.siteId)) .limit(1); await addTargets( newt.newtId, newTarget, healthCheck, resource.protocol, newt.version ); } } return response(res, { data: { ...newTarget[0], ...healthCheck[0] }, success: true, error: false, message: "Target created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/target/deleteTarget.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { newts, resources, sites, targets } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { addPeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; import { getAllowedIps } from "./helpers"; import { OpenAPITags, registry } from "@server/openApi"; const deleteTargetSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) }); registry.registerPath({ method: "delete", path: "/target/{targetId}", description: "Delete a target.", tags: [OpenAPITags.Target], request: { params: deleteTargetSchema }, responses: {} }); export async function deleteTarget( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = deleteTargetSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { targetId } = parsedParams.data; const [deletedTarget] = await db .delete(targets) .where(eq(targets.targetId, targetId)) .returning(); if (!deletedTarget) { return next( createHttpError( HttpCode.NOT_FOUND, `Target with ID ${targetId} not found` ) ); } // get the resource const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, deletedTarget.resourceId!)); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${deletedTarget.resourceId} not found` ) ); } // const [site] = await db // .select() // .from(sites) // .where(eq(sites.siteId, resource.siteId!)) // .limit(1); // // if (!site) { // return next( // createHttpError( // HttpCode.NOT_FOUND, // `Site with ID ${resource.siteId} not found` // ) // ); // } // // if (site.pubKey) { // if (site.type == "wireguard") { // await addPeer(site.exitNodeId!, { // publicKey: site.pubKey, // allowedIps: await getAllowedIps(site.siteId) // }); // } else if (site.type == "newt") { // // get the newt on the site by querying the newt table for siteId // const [newt] = await db // .select() // .from(newts) // .where(eq(newts.siteId, site.siteId)) // .limit(1); // // removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort); // } // } return response(res, { data: null, success: true, error: false, message: "Target deleted successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/target/getTarget.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, Target, targetHealthCheck, TargetHealthCheck } from "@server/db"; import { targets } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const getTargetSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) }); type GetTargetResponse = Target & Omit & { hcHeaders: { name: string; value: string }[] | null; }; registry.registerPath({ method: "get", path: "/target/{targetId}", description: "Get a target.", tags: [OpenAPITags.Target], request: { params: getTargetSchema }, responses: {} }); export async function getTarget( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getTargetSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { targetId } = parsedParams.data; const target = await db .select() .from(targets) .where(eq(targets.targetId, targetId)) .limit(1); if (target.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Target with ID ${targetId} not found` ) ); } const [targetHc] = await db .select() .from(targetHealthCheck) .where(eq(targetHealthCheck.targetId, targetId)) .limit(1); // Parse hcHeaders from JSON string back to array let parsedHcHeaders = null; if (targetHc?.hcHeaders) { try { parsedHcHeaders = JSON.parse(targetHc.hcHeaders); } catch (error) { // If parsing fails, keep as string for backward compatibility parsedHcHeaders = targetHc.hcHeaders; } } return response(res, { data: { ...target[0], ...targetHc, hcHeaders: parsedHcHeaders }, success: true, error: false, message: "Target retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/target/handleHealthcheckStatusMessage.ts ================================================ import { db, targets, resources, sites, targetHealthCheck } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { Newt } from "@server/db"; import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; import { unknown } from "zod"; interface TargetHealthStatus { status: string; lastCheck: string; checkCount: number; lastError?: string; config: { id: string; hcEnabled: boolean; hcPath?: string; hcScheme?: string; hcMode?: string; hcHostname?: string; hcPort?: number; hcInterval?: number; hcUnhealthyInterval?: number; hcTimeout?: number; hcHeaders?: any; hcMethod?: string; }; } interface HealthcheckStatusMessage { targets: Record; } export const handleHealthcheckStatusMessage: MessageHandler = async ( context ) => { const { message, client: c } = context; const newt = c as Newt; logger.info("Handling healthcheck status message"); if (!newt) { logger.warn("Newt not found"); return; } if (!newt.siteId) { logger.warn("Newt has no site ID"); return; } const data = message.data as HealthcheckStatusMessage; if (!data.targets) { logger.warn("No targets data in healthcheck status message"); return; } try { let successCount = 0; let errorCount = 0; // Process each target status update for (const [targetId, healthStatus] of Object.entries(data.targets)) { logger.debug( `Processing health status for target ${targetId}: ${healthStatus.status}${healthStatus.lastError ? ` (${healthStatus.lastError})` : ""}` ); // Verify the target belongs to this newt's site before updating // This prevents unauthorized updates to targets from other sites const targetIdNum = parseInt(targetId); if (isNaN(targetIdNum)) { logger.warn(`Invalid target ID: ${targetId}`); errorCount++; continue; } const [targetCheck] = await db .select({ targetId: targets.targetId, siteId: targets.siteId }) .from(targets) .innerJoin( resources, eq(targets.resourceId, resources.resourceId) ) .innerJoin(sites, eq(targets.siteId, sites.siteId)) .where( and( eq(targets.targetId, targetIdNum), eq(sites.siteId, newt.siteId) ) ) .limit(1); if (!targetCheck) { logger.warn( `Target ${targetId} not found or does not belong to site ${newt.siteId}` ); errorCount++; continue; } // Update the target's health status in the database await db .update(targetHealthCheck) .set({ hcHealth: healthStatus.status as | "unknown" | "healthy" | "unhealthy" }) .where(eq(targetHealthCheck.targetId, targetIdNum)) .execute(); logger.debug( `Updated health status for target ${targetId} to ${healthStatus.status}` ); successCount++; } logger.debug( `Health status update complete: ${successCount} successful, ${errorCount} errors out of ${Object.keys(data.targets).length} targets` ); } catch (error) { logger.error("Error processing healthcheck status message:", error); } return; }; ================================================ FILE: server/routers/target/helpers.ts ================================================ import { db, Transaction } from "@server/db"; import { resources, targets } from "@server/db"; import { eq } from "drizzle-orm"; const currentBannedPorts: number[] = []; export async function pickPort( siteId: number, trx: Transaction | typeof db ): Promise<{ internalPort: number; targetIps: string[]; }> { // Fetch targets for all resources of this site const targetIps: string[] = []; const targetInternalPorts: number[] = []; const targetsRes = await trx .select() .from(targets) .where(eq(targets.siteId, siteId)); targetsRes.forEach((target) => { targetIps.push(`${target.ip}/32`); if (target.internalPort) { targetInternalPorts.push(target.internalPort); } }); let internalPort!: number; // pick a port random port from 40000 to 65535 that is not in use for (let i = 0; i < 1000; i++) { internalPort = Math.floor(Math.random() * 25535) + 40000; if ( !targetInternalPorts.includes(internalPort) && !currentBannedPorts.includes(internalPort) ) { break; } } currentBannedPorts.push(internalPort); return { internalPort, targetIps }; } export async function getAllowedIps(siteId: number) { // Fetch targets for all resources of this site const targetsRes = await db .select() .from(targets) .where(eq(targets.siteId, siteId)); const targetIps = targetsRes.map((target) => `${target.ip}/32`); return targetIps.flat(); } ================================================ FILE: server/routers/target/index.ts ================================================ export * from "./getTarget"; export * from "./createTarget"; export * from "./deleteTarget"; export * from "./updateTarget"; export * from "./listTargets"; export * from "./handleHealthcheckStatusMessage"; ================================================ FILE: server/routers/target/listTargets.ts ================================================ import { db, sites, targetHealthCheck } from "@server/db"; import { targets } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, 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 logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; const listTargetsParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) }); const listTargetsSchema = z.object({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().positive()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); function queryTargets(resourceId: number) { const baseQuery = db .select({ targetId: targets.targetId, ip: targets.ip, method: targets.method, port: targets.port, enabled: targets.enabled, resourceId: targets.resourceId, siteId: targets.siteId, siteType: sites.type, hcEnabled: targetHealthCheck.hcEnabled, hcPath: targetHealthCheck.hcPath, hcScheme: targetHealthCheck.hcScheme, hcMode: targetHealthCheck.hcMode, hcHostname: targetHealthCheck.hcHostname, hcPort: targetHealthCheck.hcPort, hcInterval: targetHealthCheck.hcInterval, hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, hcTimeout: targetHealthCheck.hcTimeout, hcHeaders: targetHealthCheck.hcHeaders, hcFollowRedirects: targetHealthCheck.hcFollowRedirects, hcMethod: targetHealthCheck.hcMethod, hcStatus: targetHealthCheck.hcStatus, hcHealth: targetHealthCheck.hcHealth, hcTlsServerName: targetHealthCheck.hcTlsServerName, path: targets.path, pathMatchType: targets.pathMatchType, rewritePath: targets.rewritePath, rewritePathType: targets.rewritePathType, priority: targets.priority }) .from(targets) .leftJoin(sites, eq(sites.siteId, targets.siteId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) ) .where(eq(targets.resourceId, resourceId)); return baseQuery; } type TargetWithParsedHeaders = Omit< Awaited>[0], "hcHeaders" > & { hcHeaders: { name: string; value: string }[] | null; }; export type ListTargetsResponse = { targets: TargetWithParsedHeaders[]; pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ method: "get", path: "/resource/{resourceId}/targets", description: "List targets for a resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Target], request: { params: listTargetsParamsSchema, query: listTargetsSchema }, responses: {} }); export async function listTargets( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listTargetsSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = listTargetsParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error) ) ); } const { resourceId } = parsedParams.data; const baseQuery = queryTargets(resourceId); const countQuery = db .select({ count: sql`cast(count(*) as integer)` }) .from(targets) .where(eq(targets.resourceId, resourceId)); const targetsList = await baseQuery.limit(limit).offset(offset); const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; // Parse hcHeaders from JSON string back to array for each target const parsedTargetsList = targetsList.map((target) => { let parsedHcHeaders = null; if (target.hcHeaders) { try { parsedHcHeaders = JSON.parse(target.hcHeaders); } catch (error) { // If parsing fails, keep as string for backward compatibility parsedHcHeaders = target.hcHeaders; } } return { ...target, hcHeaders: parsedHcHeaders }; }); return response(res, { data: { targets: parsedTargetsList, pagination: { total: totalCount, limit, offset } }, success: true, error: false, message: "Targets retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/target/updateTarget.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, targetHealthCheck } from "@server/db"; import { newts, resources, sites, targets } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { addPeer } from "../gerbil/peers"; import { addTargets } from "../newt/targets"; import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; import { vs } from "@react-email/components"; const updateTargetParamsSchema = z.strictObject({ targetId: z.string().transform(Number).pipe(z.int().positive()) }); const updateTargetBodySchema = z .strictObject({ siteId: z.int().positive(), ip: z.string().refine(isTargetValid), method: z.string().min(1).max(10).optional().nullable(), port: z.int().min(1).max(65535).optional(), enabled: z.boolean().optional(), hcEnabled: z.boolean().optional().nullable(), hcPath: z.string().min(1).optional().nullable(), hcScheme: z.string().optional().nullable(), hcMode: z.string().optional().nullable(), hcHostname: z.string().optional().nullable(), hcPort: z.int().positive().optional().nullable(), hcInterval: z.int().positive().min(5).optional().nullable(), hcUnhealthyInterval: z.int().positive().min(5).optional().nullable(), hcTimeout: z.int().positive().min(1).optional().nullable(), hcHeaders: z .array(z.strictObject({ name: z.string(), value: z.string() })) .nullable() .optional(), hcFollowRedirects: z.boolean().optional().nullable(), hcMethod: z.string().min(1).optional().nullable(), hcStatus: z.int().optional().nullable(), hcTlsServerName: z.string().optional().nullable(), path: z.string().optional().nullable(), pathMatchType: z .enum(["exact", "prefix", "regex"]) .optional() .nullable(), rewritePath: z.string().optional().nullable(), rewritePathType: z .enum(["exact", "prefix", "regex", "stripPrefix"]) .optional() .nullable(), priority: z.int().min(1).max(1000).optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" }); registry.registerPath({ method: "post", path: "/target/{targetId}", description: "Update a target.", tags: [OpenAPITags.Target], request: { params: updateTargetParamsSchema, body: { content: { "application/json": { schema: updateTargetBodySchema } } } }, responses: {} }); export async function updateTarget( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = updateTargetParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = updateTargetBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { targetId } = parsedParams.data; const { siteId } = parsedBody.data; const [target] = await db .select() .from(targets) .where(eq(targets.targetId, targetId)) .limit(1); if (!target) { return next( createHttpError( HttpCode.NOT_FOUND, `Target with ID ${targetId} not found` ) ); } // get the resource const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, target.resourceId!)); if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${target.resourceId} not found` ) ); } const [site] = await db .select() .from(sites) .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { return next( createHttpError( HttpCode.NOT_FOUND, `Site with ID ${siteId} not found` ) ); } const targetData = { ...target, ...parsedBody.data }; const existingTargets = await db .select() .from(targets) .where(eq(targets.resourceId, target.resourceId)); const foundTarget = existingTargets.find( (target) => target.targetId !== targetId && // Exclude the current target being updated target.ip === targetData.ip && target.port === targetData.port && target.method === targetData.method && target.siteId === targetData.siteId ); if (foundTarget) { // log a warning logger.warn( `Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${target.resourceId}` ); } const { internalPort, targetIps } = await pickPort(site.siteId!, db); if (!internalPort) { return next( createHttpError( HttpCode.BAD_REQUEST, `No available internal port` ) ); } const [updatedTarget] = await db .update(targets) .set({ siteId: parsedBody.data.siteId, ip: parsedBody.data.ip, method: parsedBody.data.method, port: parsedBody.data.port, internalPort, enabled: parsedBody.data.enabled, path: parsedBody.data.path, pathMatchType: parsedBody.data.pathMatchType, priority: parsedBody.data.priority, rewritePath: parsedBody.data.rewritePath, rewritePathType: parsedBody.data.rewritePathType }) .where(eq(targets.targetId, targetId)) .returning(); let hcHeaders = null; if (parsedBody.data.hcHeaders) { hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); } // When health check is disabled, reset hcHealth to "unknown" // to prevent previously unhealthy targets from being excluded // Also when the site is not a newt, set hcHealth to "unknown" const hcHealthValue = parsedBody.data.hcEnabled === false || parsedBody.data.hcEnabled === null || site.type !== "newt" ? "unknown" : undefined; const [updatedHc] = await db .update(targetHealthCheck) .set({ hcEnabled: parsedBody.data.hcEnabled || false, hcPath: parsedBody.data.hcPath, hcScheme: parsedBody.data.hcScheme, hcMode: parsedBody.data.hcMode, hcHostname: parsedBody.data.hcHostname, hcPort: parsedBody.data.hcPort, hcInterval: parsedBody.data.hcInterval, hcUnhealthyInterval: parsedBody.data.hcUnhealthyInterval, hcTimeout: parsedBody.data.hcTimeout, hcHeaders: hcHeaders, hcFollowRedirects: parsedBody.data.hcFollowRedirects, hcMethod: parsedBody.data.hcMethod, hcStatus: parsedBody.data.hcStatus, hcTlsServerName: parsedBody.data.hcTlsServerName, ...(hcHealthValue !== undefined && { hcHealth: hcHealthValue }) }) .where(eq(targetHealthCheck.targetId, targetId)) .returning(); if (site.pubKey) { if (site.type == "wireguard") { await addPeer(site.exitNodeId!, { publicKey: site.pubKey, allowedIps: targetIps.flat() }); } else if (site.type == "newt") { // get the newt on the site by querying the newt table for siteId const [newt] = await db .select() .from(newts) .where(eq(newts.siteId, site.siteId)) .limit(1); await addTargets( newt.newtId, [updatedTarget], [updatedHc], resource.protocol, newt.version ); } } return response(res, { data: { ...updatedTarget, ...updatedHc }, success: true, error: false, message: "Target updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/traefik/configSchema.ts ================================================ export type DynamicTraefikConfig = { http?: Http; }; export type Http = { routers?: Routers; services?: Services; middlewares?: Middlewares; }; export type Routers = { [key: string]: Router; }; export type Router = { entryPoints: string[]; middlewares: string[]; service: string; rule: string; }; export type Services = { [key: string]: Service; }; export type Service = { loadBalancer: LoadBalancer; }; export type LoadBalancer = { servers: Server[]; }; export type Server = { url: string; }; export type Middlewares = { [key: string]: MiddlewarePlugin; }; export type MiddlewarePlugin = { plugin: Plugin; }; export type Plugin = { [key: string]: MiddlewarePluginConfig; }; export type MiddlewarePluginConfig = { [key: string]: any; }; ================================================ FILE: server/routers/traefik/index.ts ================================================ export * from "./traefikConfigProvider"; ================================================ FILE: server/routers/traefik/traefikConfigProvider.ts ================================================ import { Request, Response } from "express"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import config from "@server/lib/config"; import { build } from "@server/build"; import { getTraefikConfig } from "#dynamic/lib/traefik"; import { getCurrentExitNodeId } from "@server/lib/exitNodes"; const badgerMiddlewareName = "badger"; export async function traefikConfigProvider( _: Request, res: Response ): Promise { try { // First query to get resources with site and org info // Get the current exit node name from config const currentExitNodeId = await getCurrentExitNodeId(); const traefikConfig = await getTraefikConfig( currentExitNodeId, config.getRawConfig().traefik.site_types, build == "oss", // filter out the namespace domains in open source build != "oss", // generate the login pages on the cloud and and enterprise, config.getRawConfig().traefik.allow_raw_resources ); if (traefikConfig?.http?.middlewares) { // BECAUSE SOMETIMES THE CONFIG CAN BE EMPTY IF THERE IS NOTHING traefikConfig.http.middlewares[badgerMiddlewareName] = { plugin: { [badgerMiddlewareName]: { apiBaseUrl: new URL( "/api/v1", `http://${ config.getRawConfig().server.internal_hostname }:${config.getRawConfig().server.internal_port}` ).href, userSessionCookieName: config.getRawConfig().server.session_cookie_name, // deprecated accessTokenQueryParam: config.getRawConfig().server .resource_access_token_param, resourceSessionRequestParam: config.getRawConfig().server .resource_session_request_param } } }; } return res.status(HttpCode.OK).json(traefikConfig); } catch (e) { logger.error(`Failed to build Traefik config: ${e}`); return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ error: "Failed to build Traefik config" }); } } ================================================ FILE: server/routers/user/acceptInvite.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, orgs, UserOrg } from "@server/db"; import { roles, userInvites, userOrgs, users } from "@server/db"; 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"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { checkValidInvite } from "@server/auth/checkValidInvite"; import { verifySession } from "@server/auth/sessions/verifySession"; 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(), inviteId: z.string() }); export type AcceptInviteResponse = { accepted: boolean; orgId: string; }; export async function acceptInvite( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = acceptInviteBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { token, inviteId } = parsedBody.data; const { error, existingInvite } = await checkValidInvite({ token, inviteId }); if (error) { return next(createHttpError(HttpCode.BAD_REQUEST, error)); } if (!existingInvite) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invite does not exist") ); } const existingUser = await db .select() .from(users) .where(eq(users.email, existingInvite.email)) .limit(1); if (!existingUser.length) { return next( createHttpError( HttpCode.BAD_REQUEST, "User does not exist. Please create an account first." ) ); } const { user, session } = await verifySession(req); // at this point we know the user exists if (!user) { return next( createHttpError( HttpCode.UNAUTHORIZED, "You must be logged in to accept an invite" ) ); } if (user && user.email !== existingInvite.email) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invite is not for this user" ) ); } if (build == "saas") { const usage = await usageService.getUsage( existingInvite.orgId, FeatureId.USERS ); if (!usage) { return next( createHttpError( HttpCode.NOT_FOUND, "No usage data found for this organization" ) ); } const rejectUsers = await usageService.checkLimitSet( existingInvite.orgId, FeatureId.USERS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 } // We need to add one to know if we are violating the limit ); if (rejectUsers) { return next( createHttpError( HttpCode.FORBIDDEN, "Can not accept because this org's user limit is exceeded. Please contact your administrator to upgrade their plan." ) ); } } 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; // get the role to make sure it exists const existingRole = await db .select() .from(roles) .where(eq(roles.roleId, existingInvite.roleId)) .limit(1); if (existingRole.length) { roleId = existingRole[0].roleId; } else { // TODO: use the default role on the org instead of failing return next( createHttpError( HttpCode.BAD_REQUEST, "Role does not exist. Please contact an admin." ) ); } await db.transaction(async (trx) => { await assignUserToOrg( org, { userId: existingUser[0].userId, orgId: existingInvite.orgId, roleId: existingInvite.roleId }, trx ); // delete the invite await trx .delete(userInvites) .where(eq(userInvites.inviteId, inviteId)); await calculateUserClientsForOrgs(existingUser[0].userId, trx); logger.debug( `User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}` ); }); return response(res, { data: { accepted: true, orgId: existingInvite.orgId }, success: true, error: false, message: "Invite accepted", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/addUserAction.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { userActions, users } 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 { eq } from "drizzle-orm"; import { fromError } from "zod-validation-error"; const addUserActionSchema = z.strictObject({ userId: z.string(), actionId: z.string(), orgId: z.string() }); export async function addUserAction( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = addUserActionSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { userId, actionId, orgId } = parsedBody.data; const user = await db .select() .from(users) .where(eq(users.userId, userId)) .limit(1); if (user.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `User with ID ${userId} not found` ) ); } const newUserAction = await db .insert(userActions) .values({ userId, actionId, orgId }) .returning(); return response(res, { data: newUserAction[0], success: true, error: false, message: "Action added to user successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/addUserRole.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { clients, db, UserOrg } from "@server/db"; import { userOrgs, roles } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import stoi from "@server/lib/stoi"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; const addUserRoleParamsSchema = z.strictObject({ userId: z.string(), roleId: z.string().transform(stoi).pipe(z.number()) }); export type AddUserRoleResponse = z.infer; registry.registerPath({ method: "post", path: "/role/{roleId}/add/{userId}", description: "Add a role to a user.", tags: [OpenAPITags.Role, OpenAPITags.User], request: { params: addUserRoleParamsSchema }, responses: {} }); export async function addUserRole( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = addUserRoleParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId, roleId } = parsedParams.data; if (req.user && !req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "You do not have access to this organization" ) ); } // get the role const [role] = await db .select() .from(roles) .where(eq(roles.roleId, roleId)) .limit(1); if (!role) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID") ); } const existingUser = await db .select() .from(userOrgs) .where( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)) ) .limit(1); if (existingUser.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, "User not found or does not belong to the specified organization" ) ); } if (existingUser[0].isOwner) { return next( createHttpError( HttpCode.FORBIDDEN, "Cannot change the role of the owner of the organization" ) ); } const roleExists = await db .select() .from(roles) .where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId))) .limit(1); if (roleExists.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, "Role not found or does not belong to the specified organization" ) ); } let newUserRole: UserOrg | null = null; await db.transaction(async (trx) => { [newUserRole] = await trx .update(userOrgs) .set({ roleId }) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId) ) ) .returning(); // get the client associated with this user in this org const orgClients = await trx .select() .from(clients) .where( and( eq(clients.userId, userId), eq(clients.orgId, role.orgId) ) ) .limit(1); for (const orgClient of orgClients) { // we just changed the user's role, so we need to rebuild client associations and what they have access to await rebuildClientAssociationsFromClient(orgClient, trx); } }); return response(res, { data: newUserRole, success: true, error: false, message: "Role added to user successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/addUserSite.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resources, userResources, userSites } 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 { eq } from "drizzle-orm"; import { fromError } from "zod-validation-error"; const addUserSiteSchema = z.strictObject({ userId: z.string(), siteId: z.string().transform(Number).pipe(z.int().positive()) }); export async function addUserSite( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = addUserSiteSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { userId, siteId } = parsedBody.data; await db.transaction(async (trx) => { const newUserSite = await trx .insert(userSites) .values({ userId, siteId }) .returning(); // const siteResources = await trx // .select() // .from(resources) // .where(eq(resources.siteId, siteId)); // // for (const resource of siteResources) { // await trx.insert(userResources).values({ // userId, // resourceId: resource.resourceId // }); // } return response(res, { data: newUserSite[0], success: true, error: false, message: "Site added to user successfully", status: HttpCode.CREATED }); }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/adminGeneratePasswordResetCode.ts ================================================ import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib/response"; import { db } from "@server/db"; import { passwordResetTokens, users } from "@server/db"; import { eq } from "drizzle-orm"; import { alphabet, generateRandomString } from "oslo/crypto"; import { createDate } from "oslo"; import logger from "@server/logger"; import { TimeSpan } from "oslo"; import { hashPassword } from "@server/auth/password"; import { UserType } from "@server/types/UserTypes"; import config from "@server/lib/config"; const adminGeneratePasswordResetCodeSchema = z.strictObject({ userId: z.string().min(1) }); export type AdminGeneratePasswordResetCodeBody = z.infer< typeof adminGeneratePasswordResetCodeSchema >; export type AdminGeneratePasswordResetCodeResponse = { token: string; email: string; url: string; }; export async function adminGeneratePasswordResetCode( req: Request, res: Response, next: NextFunction ): Promise { const parsedParams = adminGeneratePasswordResetCodeSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId } = parsedParams.data; try { const existingUser = await db .select() .from(users) .where(eq(users.userId, userId)); if (!existingUser || !existingUser.length) { return next(createHttpError(HttpCode.NOT_FOUND, "User not found")); } if (existingUser[0].type !== UserType.Internal) { return next( createHttpError( HttpCode.BAD_REQUEST, "Password reset codes can only be generated for internal users" ) ); } if (!existingUser[0].email) { return next( createHttpError( HttpCode.BAD_REQUEST, "User does not have an email address" ) ); } const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); await db.transaction(async (trx) => { await trx .delete(passwordResetTokens) .where(eq(passwordResetTokens.userId, existingUser[0].userId)); const tokenHash = await hashPassword(token); await trx.insert(passwordResetTokens).values({ userId: existingUser[0].userId, email: existingUser[0].email!, tokenHash, expiresAt: createDate(new TimeSpan(2, "h")).getTime() }); }); const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${existingUser[0].email}&token=${token}`; logger.info( `Admin generated password reset code for user ${existingUser[0].email} (${userId})` ); return response(res, { data: { token, email: existingUser[0].email!, url }, success: true, error: false, message: "Password reset code generated successfully", status: HttpCode.OK }); } catch (e) { logger.error(e); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, "Failed to generate password reset code" ) ); } } ================================================ FILE: server/routers/user/adminGetUser.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { idp, users } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; const adminGetUserSchema = z.strictObject({ userId: z.string().min(1) }); registry.registerPath({ method: "get", path: "/user/{userId}", description: "Get a user by ID.", tags: [OpenAPITags.User], request: { params: adminGetUserSchema }, responses: {} }); async function queryUser(userId: string) { const [user] = await db .select({ userId: users.userId, email: users.email, username: users.username, name: users.name, type: users.type, twoFactorEnabled: users.twoFactorEnabled, twoFactorSetupRequested: users.twoFactorSetupRequested, emailVerified: users.emailVerified, serverAdmin: users.serverAdmin, idpName: idp.name, idpId: users.idpId, dateCreated: users.dateCreated }) .from(users) .leftJoin(idp, eq(users.idpId, idp.idpId)) .where(eq(users.userId, userId)) .limit(1); return user; } export type AdminGetUserResponse = NonNullable< Awaited> >; export async function adminGetUser( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = adminGetUserSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID") ); } const { userId } = parsedParams.data; const user = await queryUser(userId); if (!user) { return next( createHttpError( HttpCode.NOT_FOUND, `User with ID ${userId} not found` ) ); } return response(res, { data: user, success: true, error: false, message: "User retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/adminListUsers.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { sql, eq } from "drizzle-orm"; import logger from "@server/logger"; import { idp, users } from "@server/db"; import { fromZodError } from "zod-validation-error"; const listUsersSchema = z.strictObject({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().nonnegative()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); async function queryUsers(limit: number, offset: number) { return await db .select({ id: users.userId, email: users.email, username: users.username, name: users.name, dateCreated: users.dateCreated, serverAdmin: users.serverAdmin, type: users.type, idpName: idp.name, idpId: users.idpId, twoFactorEnabled: users.twoFactorEnabled, twoFactorSetupRequested: users.twoFactorSetupRequested }) .from(users) .leftJoin(idp, eq(users.idpId, idp.idpId)) .where(eq(users.serverAdmin, false)) .limit(limit) .offset(offset); } export type AdminListUsersResponse = { users: NonNullable>>; pagination: { total: number; limit: number; offset: number }; }; export async function adminListUsers( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listUsersSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedQuery.error) ) ); } const { limit, offset } = parsedQuery.data; const allUsers = await queryUsers(limit, offset); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(users); return response(res, { data: { users: allUsers, pagination: { total: count, limit, offset } }, success: true, error: false, message: "Users retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/adminRemoveUser.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { users } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; const removeUserSchema = z.strictObject({ userId: z.string() }); export async function adminRemoveUser( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = removeUserSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId } = parsedParams.data; // get the user first const user = await db .select() .from(users) .where(eq(users.userId, userId)); if (!user || user.length === 0) { return next(createHttpError(HttpCode.NOT_FOUND, "User not found")); } if (user[0].serverAdmin) { return next( createHttpError( HttpCode.BAD_REQUEST, "Cannot remove server admin" ) ); } await db.transaction(async (trx) => { await trx.delete(users).where(eq(users.userId, userId)); await calculateUserClientsForOrgs(userId, trx); }); return response(res, { data: null, success: true, error: false, message: "User removed successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/adminUpdateUser2FA.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { users, userOrgs } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const updateUser2FAParamsSchema = z.strictObject({ userId: z.string() }); const updateUser2FABodySchema = z.strictObject({ twoFactorSetupRequested: z.boolean() }); export type UpdateUser2FAResponse = { userId: string; twoFactorRequested: boolean; }; registry.registerPath({ method: "post", path: "/user/{userId}/2fa", description: "Update a user's 2FA status.", tags: [OpenAPITags.User], request: { params: updateUser2FAParamsSchema, body: { content: { "application/json": { schema: updateUser2FABodySchema } } } }, responses: {} }); export async function updateUser2FA( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = updateUser2FAParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = updateUser2FABodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { userId } = parsedParams.data; const { twoFactorSetupRequested } = parsedBody.data; // Verify the user exists in the organization const existingUser = await db .select() .from(users) .where(eq(users.userId, userId)) .limit(1); if (existingUser.length === 0) { return next(createHttpError(HttpCode.NOT_FOUND, "User not found")); } if (existingUser[0].type !== "internal") { return next( createHttpError( HttpCode.BAD_REQUEST, "Two-factor authentication is not supported for external users" ) ); } logger.debug( `Updating 2FA for user ${userId} to ${twoFactorSetupRequested}` ); if (twoFactorSetupRequested) { await db .update(users) .set({ twoFactorSetupRequested: true }) .where(eq(users.userId, userId)); } else { await db .update(users) .set({ twoFactorSetupRequested: false, twoFactorEnabled: false, twoFactorSecret: null }) .where(eq(users.userId, userId)); } return response(res, { data: { userId: existingUser[0].userId, twoFactorRequested: twoFactorSetupRequested }, success: true, error: false, message: `2FA ${twoFactorSetupRequested ? "enabled" : "disabled"} for user successfully`, status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/createOrgUser.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; 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 { 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"; import { FeatureId } from "@server/lib/billing"; 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() }); const bodySchema = z.strictObject({ email: z.string().email().toLowerCase().optional(), username: z.string().nonempty().toLowerCase(), name: z.string().optional(), type: z.enum(["internal", "oidc"]).optional(), idpId: z.number().optional(), roleId: z.number() }); export type CreateOrgUserResponse = {}; registry.registerPath({ method: "put", path: "/org/{orgId}/user", description: "Create an organization user.", tags: [OpenAPITags.User], request: { params: paramsSchema, body: { content: { "application/json": { schema: bodySchema } } } }, responses: {} }); export async function createOrgUser( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const parsedParams = paramsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId } = parsedParams.data; const { username, email, name, type, idpId, roleId } = parsedBody.data; if (build == "saas") { const usage = await usageService.getUsage(orgId, FeatureId.USERS); if (!usage) { return next( createHttpError( HttpCode.NOT_FOUND, "No usage data found for this organization" ) ); } const rejectUsers = await usageService.checkLimitSet( orgId, FeatureId.USERS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 } // We need to add one to know if we are violating the limit ); if (rejectUsers) { return next( createHttpError( HttpCode.FORBIDDEN, "User limit exceeded. Please upgrade your plan." ) ); } } const [role] = await db .select() .from(roles) .where(eq(roles.roleId, roleId)); if (!role) { return next( createHttpError(HttpCode.BAD_REQUEST, "Role ID not found") ); } if (type === "internal") { return next( createHttpError( HttpCode.BAD_REQUEST, "Internal users are not supported yet" ) ); } else if (type === "oidc") { if (build === "saas") { const subscribed = await isSubscribed( orgId, tierMatrix.orgOidc ); if (!subscribed) { return next( createHttpError( HttpCode.FORBIDDEN, "This organization's current plan does not support this feature." ) ); } } if (!idpId) { return next( createHttpError( HttpCode.BAD_REQUEST, "IDP ID is required for OIDC users" ) ); } 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) .innerJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(eq(idp.idpId, idpId)); if (!idpRes) { return next( createHttpError(HttpCode.BAD_REQUEST, "IDP ID not found") ); } if (idpRes.idp.type !== "oidc") { return next( createHttpError( HttpCode.BAD_REQUEST, "IDP ID is not of type OIDC" ) ); } await db.transaction(async (trx) => { const [existingUser] = await trx .select() .from(users) .where( and( eq(users.username, username), eq(users.idpId, idpId) ) ); let userId: string | undefined; if (existingUser) { userId = existingUser.userId; const [existingOrgUser] = await trx .select() .from(userOrgs) .where( and( eq(userOrgs.orgId, orgId), eq(userOrgs.userId, existingUser.userId) ) ); if (existingOrgUser) { return next( createHttpError( HttpCode.BAD_REQUEST, "User already exists in this organization" ) ); } await assignUserToOrg(org, { orgId, userId: existingUser.userId, roleId: role.roleId, autoProvisioned: false }, trx); } else { userId = generateId(15); const [newUser] = await trx .insert(users) .values({ userId: userId, email, username, name, type: "oidc", idpId, dateCreated: new Date().toISOString(), emailVerified: true }) .returning(); await assignUserToOrg(org, { orgId, userId: newUser.userId, roleId: role.roleId, autoProvisioned: false }, trx); } await calculateUserClientsForOrgs(userId, trx); }); } else { return next( createHttpError(HttpCode.BAD_REQUEST, "User type is required") ); } return response(res, { data: {}, success: true, error: false, message: "Org user created successfully", status: HttpCode.CREATED }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/getOrgUser.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idp, idpOidcConfig } from "@server/db"; import { roles, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { OpenAPITags, registry } from "@server/openApi"; export async function queryUser(orgId: string, userId: string) { const [user] = await db .select({ orgId: userOrgs.orgId, userId: users.userId, email: users.email, username: users.username, name: users.name, type: users.type, roleId: userOrgs.roleId, roleName: roles.name, isOwner: userOrgs.isOwner, isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, autoProvisioned: userOrgs.autoProvisioned, idpId: users.idpId, idpName: idp.name, idpType: idp.type, idpVariant: idpOidcConfig.variant, idpAutoProvision: idp.autoProvision }) .from(userOrgs) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); return user; } export type GetOrgUserResponse = NonNullable< Awaited> >; const getOrgUserParamsSchema = z.strictObject({ userId: z.string(), orgId: z.string() }); registry.registerPath({ method: "get", path: "/org/{orgId}/user/{userId}", description: "Get a user in an organization.", tags: [OpenAPITags.User], request: { params: getOrgUserParamsSchema }, responses: {} }); export async function getOrgUser( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getOrgUserParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId, userId } = parsedParams.data; if (!req.userOrg) { return next( createHttpError( HttpCode.FORBIDDEN, "You do not have access to this organization" ) ); } let user; user = await queryUser(orgId, userId); if (!user) { const [fullUser] = await db .select() .from(users) .where(eq(users.email, userId)) .limit(1); if (fullUser) { user = await queryUser(orgId, fullUser.userId); } } if (!user) { return next( createHttpError( HttpCode.NOT_FOUND, `User with ID ${userId} not found in org` ) ); } if (req.user && user.userId !== req.userOrg.userId) { const hasPermission = await checkUserActionPermission( ActionsEnum.getOrgUser, req ); if (!hasPermission) { return next( createHttpError( HttpCode.FORBIDDEN, "User does not have permission perform this action" ) ); } } return response(res, { data: user, success: true, error: false, message: "User retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/getOrgUserByUsername.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { queryUser, type GetOrgUserResponse } from "./getOrgUser"; const getOrgUserByUsernameParamsSchema = z.strictObject({ orgId: z.string() }); const getOrgUserByUsernameQuerySchema = z.strictObject({ username: z.string().min(1, "username is required"), idpId: z .string() .optional() .transform((v) => v === undefined || v === "" ? undefined : parseInt(v, 10) ) .refine( (v) => v === undefined || (Number.isInteger(v) && (v as number) > 0), { message: "idpId must be a positive integer" } ) }); registry.registerPath({ method: "get", path: "/org/{orgId}/user-by-username", description: "Get a user in an organization by username. When idpId is not passed, only internal users are searched (username is globally unique for them). For external (OIDC) users, pass idpId to search by username within that identity provider.", tags: [OpenAPITags.User], request: { params: getOrgUserByUsernameParamsSchema, query: getOrgUserByUsernameQuerySchema }, responses: {} }); export async function getOrgUserByUsername( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = getOrgUserByUsernameParamsSchema.safeParse( req.params ); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedQuery = getOrgUserByUsernameQuerySchema.safeParse( req.query ); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error).toString() ) ); } const { orgId } = parsedParams.data; const { username, idpId } = parsedQuery.data; const conditions = [ eq(userOrgs.orgId, orgId), eq(users.username, username) ]; if (idpId !== undefined) { conditions.push(eq(users.idpId, idpId)); } else { conditions.push(eq(users.type, "internal")); } const candidates = await db .select({ userId: users.userId }) .from(userOrgs) .innerJoin(users, eq(userOrgs.userId, users.userId)) .where(and(...conditions)); if (candidates.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `User with username '${username}' not found in organization` ) ); } if (candidates.length > 1) { return next( createHttpError( HttpCode.BAD_REQUEST, "Multiple users with this username (external users from different identity providers). Specify idpId (identity provider ID) to disambiguate. When not specified, this searches for internal users only." ) ); } const user = await queryUser(orgId, candidates[0].userId); if (!user) { return next( createHttpError( HttpCode.NOT_FOUND, `User with username '${username}' not found in organization` ) ); } return response(res, { data: user, success: true, error: false, message: "User retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/getUser.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { idp, users } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; async function queryUser(userId: string) { const [user] = await db .select({ userId: users.userId, email: users.email, username: users.username, name: users.name, type: users.type, twoFactorEnabled: users.twoFactorEnabled, emailVerified: users.emailVerified, serverAdmin: users.serverAdmin, idpName: idp.name, idpId: users.idpId }) .from(users) .leftJoin(idp, eq(users.idpId, idp.idpId)) .where(eq(users.userId, userId)) .limit(1); return user; } export type GetUserResponse = NonNullable< Awaited> >; export async function getUser( req: Request, res: Response, next: NextFunction ): Promise { try { const userId = req.user?.userId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not found") ); } const user = await queryUser(userId); if (!user) { return next( createHttpError( HttpCode.NOT_FOUND, `User with ID ${userId} not found` ) ); } return response(res, { data: user, success: true, error: false, message: "User retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/index.ts ================================================ export * from "./getUser"; export * from "./removeUserOrg"; export * from "./listUsers"; export * from "./addUserRole"; export * from "./inviteUser"; export * from "./acceptInvite"; export * from "./getOrgUser"; export * from "./getOrgUserByUsername"; export * from "./adminListUsers"; export * from "./adminRemoveUser"; export * from "./adminGetUser"; export * from "./adminGeneratePasswordResetCode"; export * from "./listInvitations"; export * from "./removeInvitation"; export * from "./createOrgUser"; export * from "./adminUpdateUser2FA"; export * from "./adminGetUser"; export * from "./updateOrgUser"; export * from "./myDevice"; ================================================ FILE: server/routers/user/inviteUser.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { orgs, roles, userInvites, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { alphabet, generateRandomString } from "oslo/crypto"; import { createDate, TimeSpan } from "oslo"; import config from "@server/lib/config"; import { hashPassword } from "@server/auth/password"; import { fromError } from "zod-validation-error"; import { sendEmail } from "@server/emails"; import SendInviteLink from "@server/emails/templates/SendInviteLink"; import { OpenAPITags, registry } from "@server/openApi"; import { UserType } from "@server/types/UserTypes"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; import cache from "#dynamic/lib/cache"; const inviteUserParamsSchema = z.strictObject({ orgId: z.string() }); const inviteUserBodySchema = z.strictObject({ email: z.email().toLowerCase(), roleId: z.number(), validHours: z.number().gt(0).lte(168), sendEmail: z.boolean().optional(), regenerate: z.boolean().optional() }); export type InviteUserBody = z.infer; export type InviteUserResponse = { inviteLink: string; expiresAt: number; }; registry.registerPath({ method: "post", path: "/org/{orgId}/create-invite", description: "Invite a user to join an organization.", tags: [OpenAPITags.Invitation], request: { params: inviteUserParamsSchema, body: { content: { "application/json": { schema: inviteUserBodySchema } } } }, responses: {} }); export async function inviteUser( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = inviteUserParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const parsedBody = inviteUserBodySchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { orgId } = parsedParams.data; const { email, validHours, roleId, sendEmail: doEmail, regenerate } = parsedBody.data; // Check if the organization exists const org = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); if (!org.length) { return next( createHttpError(HttpCode.NOT_FOUND, "Organization not found") ); } // Validate that the roleId belongs to the target organization const [role] = await db .select() .from(roles) .where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId))) .limit(1); if (!role) { return next( createHttpError( HttpCode.BAD_REQUEST, "Invalid role ID or role does not belong to this organization" ) ); } if (build == "saas") { const usage = await usageService.getUsage(orgId, FeatureId.USERS); if (!usage) { return next( createHttpError( HttpCode.NOT_FOUND, "No usage data found for this organization" ) ); } const rejectUsers = await usageService.checkLimitSet( orgId, FeatureId.USERS, { ...usage, instantaneousValue: (usage.instantaneousValue || 0) + 1 } // We need to add one to know if we are violating the limit ); if (rejectUsers) { return next( createHttpError( HttpCode.FORBIDDEN, "User limit exceeded. Please upgrade your plan." ) ); } } // Check if the user already exists in the `users` table const existingUser = await db .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where( and( eq(users.email, email), eq(userOrgs.orgId, orgId), eq(users.type, UserType.Internal) ) ) .limit(1); if (existingUser.length) { return next( createHttpError( HttpCode.CONFLICT, "This user is already a member of the organization." ) ); } // Check if an invitation already exists const existingInvite = await db .select() .from(userInvites) .where( and(eq(userInvites.email, email), eq(userInvites.orgId, orgId)) ) .limit(1); if (existingInvite.length && !regenerate) { return next( createHttpError( HttpCode.CONFLICT, "An invitation for this user already exists." ) ); } if (existingInvite.length) { const attempts = (await cache.get(email)) || 0; if (attempts >= 3) { return next( createHttpError( HttpCode.TOO_MANY_REQUESTS, "You have exceeded the limit of 3 regenerations per hour." ) ); } await cache.set(email, attempts + 1); const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId const token = generateRandomString( 32, alphabet("a-z", "A-Z", "0-9") ); const expiresAt = createDate( new TimeSpan(validHours, "h") ).getTime(); const tokenHash = await hashPassword(token); await db .update(userInvites) .set({ tokenHash, expiresAt }) .where( and( eq(userInvites.email, email), eq(userInvites.orgId, orgId) ) ); const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; if (doEmail) { await sendEmail( SendInviteLink({ email, inviteLink, expiresInDays: (validHours / 24).toString(), orgName: org[0].name || orgId, inviterName: req.user?.email || req.user?.username }), { to: email, from: config.getNoReplyEmail(), subject: "Your invitation has been regenerated" } ); } return response(res, { data: { inviteLink, expiresAt }, success: true, error: false, message: "Invitation regenerated successfully", status: HttpCode.OK }); } // Create a new invite if none exists const inviteId = generateRandomString( 10, alphabet("a-z", "A-Z", "0-9") ); const token = generateRandomString(32, alphabet("a-z", "A-Z", "0-9")); const expiresAt = createDate(new TimeSpan(validHours, "h")).getTime(); const tokenHash = await hashPassword(token); await db.transaction(async (trx) => { await trx.insert(userInvites).values({ inviteId, orgId, email, expiresAt, tokenHash, roleId }); }); const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; if (doEmail) { await sendEmail( SendInviteLink({ email, inviteLink, expiresInDays: (validHours / 24).toString(), orgName: org[0].name || orgId, inviterName: req.user?.email || req.user?.username }), { to: email, from: config.getNoReplyEmail(), subject: `You're invited to join ${org[0].name || orgId}` } ); } return response(res, { data: { inviteLink, expiresAt }, success: true, error: false, message: "User invited successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/listInvitations.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { userInvites, roles } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const listInvitationsParamsSchema = z.strictObject({ orgId: z.string() }); const listInvitationsQuerySchema = z.strictObject({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().nonnegative()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); async function queryInvitations(orgId: string, limit: number, offset: number) { return await db .select({ inviteId: userInvites.inviteId, email: userInvites.email, expiresAt: userInvites.expiresAt, roleId: userInvites.roleId, roleName: roles.name }) .from(userInvites) .leftJoin(roles, sql`${userInvites.roleId} = ${roles.roleId}`) .where(sql`${userInvites.orgId} = ${orgId}`) .limit(limit) .offset(offset); } export type ListInvitationsResponse = { invitations: NonNullable>>; pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ method: "get", path: "/org/{orgId}/invitations", description: "List invitations in an organization.", tags: [OpenAPITags.Invitation], request: { params: listInvitationsParamsSchema, query: listInvitationsQuerySchema }, responses: {} }); export async function listInvitations( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listInvitationsQuerySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedQuery.error) ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = listInvitationsParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const { orgId } = parsedParams.data; const invitations = await queryInvitations(orgId, limit, offset); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(userInvites) .where(sql`${userInvites.orgId} = ${orgId}`); return response(res, { data: { invitations, pagination: { total: count, limit, offset } }, success: true, error: false, message: "Invitations retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/listUsers.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idpOidcConfig } from "@server/db"; import { idp, roles, userOrgs, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import { and, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { eq } from "drizzle-orm"; const listUsersParamsSchema = z.strictObject({ orgId: z.string() }); const listUsersSchema = z.strictObject({ limit: z .string() .optional() .default("1000") .transform(Number) .pipe(z.int().nonnegative()), offset: z .string() .optional() .default("0") .transform(Number) .pipe(z.int().nonnegative()) }); async function queryUsers(orgId: string, limit: number, offset: number) { return await db .select({ id: users.userId, email: users.email, emailVerified: users.emailVerified, dateCreated: users.dateCreated, orgId: userOrgs.orgId, username: users.username, name: users.name, type: users.type, roleId: userOrgs.roleId, roleName: roles.name, isOwner: userOrgs.isOwner, idpName: idp.name, idpId: users.idpId, idpType: idp.type, idpVariant: idpOidcConfig.variant, twoFactorEnabled: users.twoFactorEnabled }) .from(users) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .where(eq(userOrgs.orgId, orgId)) .limit(limit) .offset(offset); } export type ListUsersResponse = { users: NonNullable>>; pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ method: "get", path: "/org/{orgId}/users", description: "List users in an organization.", tags: [OpenAPITags.User], request: { params: listUsersParamsSchema, query: listUsersSchema }, responses: {} }); export async function listUsers( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = listUsersSchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedQuery.error) ) ); } const { limit, offset } = parsedQuery.data; const parsedParams = listUsersParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromZodError(parsedParams.error) ) ); } const { orgId } = parsedParams.data; const usersWithRoles = await queryUsers( orgId.toString(), limit, offset ); const [{ count }] = await db .select({ count: sql`count(*)` }) .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); return response(res, { data: { users: usersWithRoles, pagination: { total: count, limit, offset } }, success: true, error: false, message: "Users retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/myDevice.ts ================================================ import { Request, Response, NextFunction } from "express"; import { db, Olm, olms, orgs, userOrgs } from "@server/db"; import { idp, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { GetUserResponse } from "./getUser"; import { z } from "zod"; import { fromError } from "zod-validation-error"; const querySchema = z.object({ olmId: z.string() }); type ResponseOrg = { orgId: string; orgName: string; roleId: number; }; export type MyDeviceResponse = { user: GetUserResponse; orgs: ResponseOrg[]; olm: Olm | null; }; export async function myDevice( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedQuery = querySchema.safeParse(req.query); if (!parsedQuery.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedQuery.error) ) ); } const { olmId } = parsedQuery.data; const userId = req.user?.userId; if (!userId) { return next( createHttpError(HttpCode.UNAUTHORIZED, "User not found") ); } const [user] = await db .select({ userId: users.userId, email: users.email, username: users.username, name: users.name, type: users.type, twoFactorEnabled: users.twoFactorEnabled, emailVerified: users.emailVerified, serverAdmin: users.serverAdmin, idpName: idp.name, idpId: users.idpId }) .from(users) .leftJoin(idp, eq(users.idpId, idp.idpId)) .where(eq(users.userId, userId)) .limit(1); if (!user) { return next( createHttpError( HttpCode.NOT_FOUND, `User with ID ${userId} not found` ) ); } const [olm] = await db .select() .from(olms) .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); const userOrganizations = await db .select({ orgId: userOrgs.orgId, orgName: orgs.name, roleId: userOrgs.roleId }) .from(userOrgs) .where(eq(userOrgs.userId, userId)) .innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)); return response(res, { data: { user, orgs: userOrganizations, olm }, success: true, error: false, message: "My device retrieved successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/removeInvitation.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { userInvites } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const removeInvitationParamsSchema = z.strictObject({ orgId: z.string(), inviteId: z.string() }); registry.registerPath({ method: "delete", path: "/org/{orgId}/invitations/{inviteId}", description: "Remove an open invitation from an organization", tags: [OpenAPITags.Invitation], request: { params: removeInvitationParamsSchema }, responses: {} }); export async function removeInvitation( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = removeInvitationParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { orgId, inviteId } = parsedParams.data; const deletedInvitation = await db .delete(userInvites) .where( and( eq(userInvites.orgId, orgId), eq(userInvites.inviteId, inviteId) ) ) .returning(); if (deletedInvitation.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Invitation with ID ${inviteId} not found in organization ${orgId}` ) ); } return response(res, { data: null, success: true, error: false, message: "Invitation removed successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/removeUserAction.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { userActions } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const removeUserActionParamsSchema = z.strictObject({ userId: z.string() }); const removeUserActionSchema = z.strictObject({ actionId: z.string(), orgId: z.string() }); export async function removeUserAction( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = removeUserActionParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId } = parsedParams.data; const parsedBody = removeUserActionSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { actionId, orgId } = parsedBody.data; const deletedUserAction = await db .delete(userActions) .where( and( eq(userActions.userId, userId), eq(userActions.actionId, actionId), eq(userActions.orgId, orgId) ) ) .returning(); if (deletedUserAction.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Action with ID ${actionId} not found for user with ID ${userId} in organization ${orgId}` ) ); } return response(res, { data: null, success: true, error: false, message: "Action removed from user successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/removeUserOrg.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, orgs, resources, siteResources, sites, UserOrg, userSiteResources } from "@server/db"; import { userOrgs, userResources, users, userSites } from "@server/db"; 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"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { usageService } from "@server/lib/billing/usageService"; 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(), orgId: z.string() }); registry.registerPath({ method: "delete", path: "/org/{orgId}/user/{userId}", description: "Remove a user from an organization.", tags: [OpenAPITags.User], request: { params: removeUserSchema }, responses: {} }); export async function removeUserOrg( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = removeUserSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId, orgId } = parsedParams.data; // get the user first const [user] = await db .select() .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))); if (!user) { return next(createHttpError(HttpCode.NOT_FOUND, "User not found")); } if (user.isOwner) { return next( createHttpError( HttpCode.BAD_REQUEST, "Cannot remove owner from org" ) ); } 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 removeUserFromOrg(org, userId, trx); // if (build === "saas") { // const [rootUser] = await trx // .select() // .from(users) // .where(eq(users.userId, userId)); // // const [leftInOrgs] = await trx // .select({ count: count() }) // .from(userOrgs) // .where(eq(userOrgs.userId, userId)); // // // if the user is not an internal user and does not belong to any org, delete the entire user // if (rootUser?.type !== UserType.Internal && !leftInOrgs.count) { // await trx.delete(users).where(eq(users.userId, userId)); // } // } await calculateUserClientsForOrgs(userId, trx); }); return response(res, { data: null, success: true, error: false, message: "User removed from org successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/removeUserResource.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { userResources } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const removeUserResourceSchema = z.strictObject({ userId: z.string(), resourceId: z.string().transform(Number).pipe(z.int().positive()) }); export async function removeUserResource( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = removeUserResourceSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId, resourceId } = parsedParams.data; const deletedUserResource = await db .delete(userResources) .where( and( eq(userResources.userId, userId), eq(userResources.resourceId, resourceId) ) ) .returning(); if (deletedUserResource.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Resource with ID ${resourceId} not found for user with ID ${userId}` ) ); } return response(res, { data: null, success: true, error: false, message: "Resource removed from user successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/removeUserSite.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { resources, userResources, userSites } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; const removeUserSiteParamsSchema = z.strictObject({ userId: z.string() }); const removeUserSiteSchema = z.strictObject({ siteId: z.int().positive() }); export async function removeUserSite( req: Request, res: Response, next: NextFunction ): Promise { try { const parsedParams = removeUserSiteParamsSchema.safeParse(req.params); if (!parsedParams.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedParams.error).toString() ) ); } const { userId } = parsedParams.data; const parsedBody = removeUserSiteSchema.safeParse(req.body); if (!parsedBody.success) { return next( createHttpError( HttpCode.BAD_REQUEST, fromError(parsedBody.error).toString() ) ); } const { siteId } = parsedBody.data; await db.transaction(async (trx) => { const deletedUserSite = await trx .delete(userSites) .where( and( eq(userSites.userId, userId), eq(userSites.siteId, siteId) ) ) .returning(); if (deletedUserSite.length === 0) { return next( createHttpError( HttpCode.NOT_FOUND, `Site with ID ${siteId} not found for user with ID ${userId}` ) ); } // const siteResources = await trx // .select() // .from(resources) // .where(eq(resources.siteId, siteId)); // // for (const resource of siteResources) { // await trx // .delete(userResources) // .where( // and( // eq(userResources.userId, userId), // eq(userResources.resourceId, resource.resourceId) // ) // ) // .returning(); // } }); return response(res, { data: null, success: true, error: false, message: "Site removed from user successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/user/updateOrgUser.ts ================================================ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z.strictObject({ userId: z.string(), orgId: z.string() }); const bodySchema = z .strictObject({ autoProvisioned: z.boolean().optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" }); registry.registerPath({ method: "post", path: "/org/{orgId}/user/{userId}", description: "Update a user in an org.", tags: [OpenAPITags.Org], request: { params: paramsSchema, body: { content: { "application/json": { schema: bodySchema } } } }, responses: {} }); export async function updateOrgUser( req: Request, res: Response, next: NextFunction ): Promise { 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 { userId, orgId } = parsedParams.data; const [existingUser] = await db .select() .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); if (!existingUser) { return next( createHttpError( HttpCode.NOT_FOUND, "User not found in this organization" ) ); } const updateData = parsedBody.data; const [updatedUser] = await db .update(userOrgs) .set({ ...updateData }) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .returning(); return response(res, { data: updatedUser, success: true, error: false, message: "Org user updated successfully", status: HttpCode.OK }); } catch (error) { logger.error(error); return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } } ================================================ FILE: server/routers/ws/checkRoundTripMessage.ts ================================================ 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 { 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") ); } } ================================================ FILE: server/routers/ws/handleRoundTripMessage.ts ================================================ 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; }; ================================================ FILE: server/routers/ws/index.ts ================================================ export * from "./ws"; export * from "./types"; export * from "./checkRoundTripMessage"; ================================================ FILE: server/routers/ws/messageHandlers.ts ================================================ import { build } from "@server/build"; import { handleNewtRegisterMessage, handleReceiveBandwidthMessage, handleGetConfigMessage, handleDockerStatusMessage, handleDockerContainersMessage, handleNewtPingRequestMessage, handleApplyBlueprintMessage, handleNewtPingMessage, startNewtOfflineChecker, handleNewtDisconnectingMessage } from "../newt"; import { handleOlmRegisterMessage, handleOlmRelayMessage, handleOlmPingMessage, startOlmOfflineChecker, handleOlmServerPeerAddMessage, handleOlmUnRelayMessage, handleOlmDisconnectingMessage, handleOlmServerInitAddPeerHandshake } from "../olm"; import { handleHealthcheckStatusMessage } from "../target"; import { handleRoundTripMessage } from "./handleRoundTripMessage"; import { MessageHandler } from "./types"; export const messageHandlers: Record = { "olm/wg/server/peer/add": handleOlmServerPeerAddMessage, "olm/wg/server/peer/init": handleOlmServerInitAddPeerHandshake, "olm/wg/register": handleOlmRegisterMessage, "olm/wg/relay": handleOlmRelayMessage, "olm/wg/unrelay": handleOlmUnRelayMessage, "olm/ping": handleOlmPingMessage, "olm/disconnecting": handleOlmDisconnectingMessage, "newt/disconnecting": handleNewtDisconnectingMessage, "newt/ping": handleNewtPingMessage, "newt/wg/register": handleNewtRegisterMessage, "newt/wg/get-config": handleGetConfigMessage, "newt/receive-bandwidth": handleReceiveBandwidthMessage, "newt/socket/status": handleDockerStatusMessage, "newt/socket/containers": handleDockerContainersMessage, "newt/ping/request": handleNewtPingRequestMessage, "newt/blueprint/apply": handleApplyBlueprintMessage, "newt/healthcheck/status": handleHealthcheckStatusMessage, "ws/round-trip/complete": handleRoundTripMessage }; if (build != "saas") { startOlmOfflineChecker(); // this is to handle the offline check for olms startNewtOfflineChecker(); // this is to handle the offline check for newts } ================================================ FILE: server/routers/ws/types.ts ================================================ import { Newt, newts, NewtSession, olms, Olm, OlmSession, RemoteExitNode, RemoteExitNodeSession, remoteExitNodes } from "@server/db"; import { IncomingMessage } from "http"; import { WebSocket } from "ws"; // Custom interfaces export interface WebSocketRequest extends IncomingMessage { token?: string; } export type ClientType = "newt" | "olm" | "remoteExitNode"; export interface AuthenticatedWebSocket extends WebSocket { client?: Newt | Olm | RemoteExitNode; clientType?: ClientType; connectionId?: string; isFullyConnected?: boolean; pendingMessages?: { data: Buffer; isBinary: boolean }[]; configVersion?: number; } export interface TokenPayload { client: Newt | Olm | RemoteExitNode; session: NewtSession | OlmSession | RemoteExitNodeSession; clientType: ClientType; } export interface WSMessage { type: string; data: any; configVersion?: number; } export interface HandlerResponse { message: WSMessage; broadcast?: boolean; excludeSender?: boolean; targetClientId?: string; options?: SendMessageOptions; } export interface HandlerContext { message: WSMessage; senderWs: WebSocket; client: Newt | Olm | RemoteExitNode | undefined; clientType: ClientType; sendToClient: ( clientId: string, message: WSMessage, options?: SendMessageOptions ) => Promise; broadcastToAllExcept: ( message: WSMessage, excludeClientId?: string, options?: SendMessageOptions ) => Promise; connectedClients: Map; } export type MessageHandler = ( context: HandlerContext ) => Promise; // Options for sending messages with config version tracking export interface SendMessageOptions { incrementConfigVersion?: boolean; compress?: boolean; } // Redis message type for cross-node communication export interface RedisMessage { type: "direct" | "broadcast"; targetClientId?: string; excludeClientId?: string; message: WSMessage; fromNodeId: string; options?: SendMessageOptions; } ================================================ FILE: server/routers/ws/ws.ts ================================================ import { Router, Request, Response } from "express"; import zlib from "zlib"; import { Server as HttpServer } from "http"; import { WebSocket, WebSocketServer } from "ws"; import { Socket } from "net"; import { Newt, newts, NewtSession, olms, Olm, OlmSession, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { db } from "@server/db"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import { messageHandlers } from "./messageHandlers"; import logger from "@server/logger"; import { v4 as uuidv4 } from "uuid"; import { ClientType, TokenPayload, WebSocketRequest, WSMessage, AuthenticatedWebSocket, SendMessageOptions } from "./types"; import { validateSessionToken } from "@server/auth/sessions/app"; // Subset of TokenPayload for public ws.ts (newt and olm only) interface PublicTokenPayload { client: Newt | Olm; session: NewtSession | OlmSession; clientType: "newt" | "olm"; } const router: Router = Router(); const wss: WebSocketServer = new WebSocketServer({ noServer: true }); // Generate unique node ID for this instance const NODE_ID = uuidv4(); // Client tracking map (local to this node) const connectedClients: Map = new Map(); // Config version tracking map (clientId -> version) const clientConfigVersions: Map = new Map(); // Helper to get map key const getClientMapKey = (clientId: string) => clientId; // Helper functions for client management const addClient = async ( clientType: ClientType, clientId: string, ws: AuthenticatedWebSocket ): Promise => { // Generate unique connection ID const connectionId = uuidv4(); ws.connectionId = connectionId; // Add to local tracking const mapKey = getClientMapKey(clientId); const existingClients = connectedClients.get(mapKey) || []; existingClients.push(ws); connectedClients.set(mapKey, existingClients); // Initialize config version to 0 if not already set, otherwise use existing if (!clientConfigVersions.has(clientId)) { clientConfigVersions.set(clientId, 0); } // Set the current config version on the websocket ws.configVersion = clientConfigVersions.get(clientId) || 0; logger.info( `Client added to tracking - ${clientType.toUpperCase()} ID: ${clientId}, Connection ID: ${connectionId}, Total connections: ${existingClients.length}` ); }; const removeClient = async ( clientType: ClientType, clientId: string, ws: AuthenticatedWebSocket ): Promise => { const mapKey = getClientMapKey(clientId); const existingClients = connectedClients.get(mapKey) || []; const updatedClients = existingClients.filter((client) => client !== ws); if (updatedClients.length === 0) { connectedClients.delete(mapKey); logger.info( `All connections removed for ${clientType.toUpperCase()} ID: ${clientId}` ); } else { connectedClients.set(mapKey, updatedClients); logger.info( `Connection removed - ${clientType.toUpperCase()} ID: ${clientId}, Remaining connections: ${updatedClients.length}` ); } }; // Local message sending (within this node) const sendToClientLocal = async ( clientId: string, message: WSMessage, options: SendMessageOptions = {} ): Promise => { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); if (!clients || clients.length === 0) { return false; } // Include config version in message const configVersion = clientConfigVersions.get(clientId) || 0; // Update version on all client connections clients.forEach((client) => { client.configVersion = configVersion; }); const messageWithVersion = { ...message, configVersion }; const messageString = JSON.stringify(messageWithVersion); if (options.compress) { const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8")); clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(compressed); } }); } else { clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(messageString); } }); } return true; }; const broadcastToAllExceptLocal = async ( message: WSMessage, excludeClientId?: string, options: SendMessageOptions = {} ): Promise => { connectedClients.forEach((clients, mapKey) => { const clientId = mapKey; // mapKey is the clientId if (!(excludeClientId && clientId === excludeClientId)) { // Handle config version per client if (options.incrementConfigVersion) { const currentVersion = clientConfigVersions.get(clientId) || 0; const newVersion = currentVersion + 1; clientConfigVersions.set(clientId, newVersion); clients.forEach((client) => { client.configVersion = newVersion; }); } // Include config version in message for this client const configVersion = clientConfigVersions.get(clientId) || 0; const messageWithVersion = { ...message, configVersion }; if (options.compress) { const compressed = zlib.gzipSync( Buffer.from(JSON.stringify(messageWithVersion), "utf8") ); clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(compressed); } }); } else { clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(JSON.stringify(messageWithVersion)); } }); } } }); }; // Cross-node message sending const sendToClient = async ( clientId: string, message: WSMessage, options: SendMessageOptions = {} ): Promise => { // Increment config version if requested if (options.incrementConfigVersion) { const currentVersion = clientConfigVersions.get(clientId) || 0; const newVersion = currentVersion + 1; clientConfigVersions.set(clientId, newVersion); } // Try to send locally first const localSent = await sendToClientLocal(clientId, message, options); logger.debug( `sendToClient: Message type ${message.type} sent to clientId ${clientId}` ); return localSent; }; const broadcastToAllExcept = async ( message: WSMessage, excludeClientId?: string, options: SendMessageOptions = {} ): Promise => { // Broadcast locally await broadcastToAllExceptLocal(message, excludeClientId, options); }; // Check if a client has active connections across all nodes const hasActiveConnections = async (clientId: string): Promise => { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); return !!(clients && clients.length > 0); }; // Get the current config version for a client const getClientConfigVersion = async (clientId: string): Promise => { const version = clientConfigVersions.get(clientId); logger.debug(`getClientConfigVersion called for clientId: ${clientId}, returning: ${version} (type: ${typeof version})`); return version; }; // Get all active nodes for a client const getActiveNodes = async ( clientType: ClientType, clientId: string ): Promise => { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); return clients && clients.length > 0 ? [NODE_ID] : []; }; // Token verification middleware const verifyToken = async ( token: string, clientType: ClientType, userToken: string ): Promise => { try { if (clientType === "newt") { const { session, newt } = await validateNewtSessionToken(token); if (!session || !newt) { return null; } const existingNewt = await db .select() .from(newts) .where(eq(newts.newtId, newt.newtId)); if (!existingNewt || !existingNewt[0]) { return null; } return { client: existingNewt[0], session, clientType }; } else if (clientType === "olm") { const { session, olm } = await validateOlmSessionToken(token); if (!session || !olm) { return null; } const existingOlm = await db .select() .from(olms) .where(eq(olms.olmId, olm.olmId)); if (!existingOlm || !existingOlm[0]) { return null; } if (olm.userId) { // this is a user device and we need to check the user token const { session: userSession, user } = await validateSessionToken(userToken); if (!userSession || !user) { return null; } if (user.userId !== olm.userId) { return null; } } return { client: existingOlm[0], session, clientType }; } return null; } catch (error) { logger.error("Token verification failed:", error); return null; } }; const setupConnection = async ( ws: AuthenticatedWebSocket, client: Newt | Olm, clientType: "newt" | "olm" ): Promise => { logger.info("Establishing websocket connection"); if (!client) { logger.error("Connection attempt without client"); return ws.terminate(); } ws.client = client; ws.clientType = clientType; // Add client to tracking const clientId = clientType === "newt" ? (client as Newt).newtId : (client as Olm).olmId; await addClient(clientType, clientId, ws); ws.on("message", async (data, isBinary) => { try { const messageBuffer = isBinary ? zlib.gunzipSync(data as Buffer) : (data as Buffer); const message: WSMessage = JSON.parse(messageBuffer.toString()); if (!message.type || typeof message.type !== "string") { throw new Error( "Invalid message format: missing or invalid type" ); } const handler = messageHandlers[message.type]; if (!handler) { throw new Error(`Unsupported message type: ${message.type}`); } const response = await handler({ message, senderWs: ws, client: ws.client, clientType: ws.clientType!, sendToClient, broadcastToAllExcept, connectedClients }); if (response) { if (response.broadcast) { await broadcastToAllExcept( response.message, response.excludeSender ? clientId : undefined, response.options ); } else if (response.targetClientId) { await sendToClient( response.targetClientId, response.message, response.options ); } else { await sendToClient( clientId, response.message, response.options ); } } } catch (error) { logger.error("Message handling error:", error); ws.send( JSON.stringify({ type: "error", data: { message: error instanceof Error ? error.message : "Unknown error occurred", originalMessage: data.toString() } }) ); } }); ws.on("close", () => { removeClient(clientType, clientId, ws); logger.info( `Client disconnected - ${clientType.toUpperCase()} ID: ${clientId}` ); }); // Handle WebSocket protocol-level pings from older newt clients that do // not send application-level "newt/ping" messages. Update the site's // online state and lastPing timestamp so the offline checker treats them // the same as modern newt clients. if (clientType === "newt") { const newtClient = client as Newt; ws.on("ping", async () => { if (!newtClient.siteId) return; try { await db .update(sites) .set({ online: true, lastPing: Math.floor(Date.now() / 1000) }) .where(eq(sites.siteId, newtClient.siteId)); } catch (error) { logger.error( "Error updating newt site online state on WS ping", { error } ); } }); } ws.on("error", (error: Error) => { logger.error( `WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`, error ); }); logger.info( `WebSocket connection established - ${clientType.toUpperCase()} ID: ${clientId}` ); }; // Router endpoint router.get("/ws", (req: Request, res: Response) => { res.status(200).send("WebSocket endpoint"); }); // WebSocket upgrade handler const handleWSUpgrade = (server: HttpServer): void => { server.on( "upgrade", async (request: WebSocketRequest, socket: Socket, head: Buffer) => { try { const url = new URL( request.url || "", `http://${request.headers.host}` ); const token = url.searchParams.get("token") || request.headers["sec-websocket-protocol"] || ""; const userToken = url.searchParams.get("userToken") || ""; let clientType = url.searchParams.get( "clientType" ) as ClientType; if (!clientType) { clientType = "newt"; } if ( !token || !clientType || !["newt", "olm"].includes(clientType) ) { logger.warn( "Unauthorized connection attempt: invalid token or client type..." ); socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); return; } const tokenPayload = await verifyToken( token, clientType, userToken ); if (!tokenPayload) { logger.warn( "Unauthorized connection attempt: invalid token..." ); socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n"); socket.destroy(); return; } wss.handleUpgrade( request, socket, head, (ws: AuthenticatedWebSocket) => { setupConnection( ws, tokenPayload.client, tokenPayload.clientType ); } ); } catch (error) { logger.error("WebSocket upgrade error:", error); socket.write("HTTP/1.1 500 Internal Server Error\r\n\r\n"); socket.destroy(); } } ); }; // Disconnect a specific client and force them to reconnect const disconnectClient = async (clientId: string): Promise => { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); if (!clients || clients.length === 0) { logger.debug(`No connections found for client ID: ${clientId}`); return false; } logger.info( `Disconnecting client ID: ${clientId} (${clients.length} connection(s))` ); // Close all connections for this client clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.close(1000, "Disconnected by server"); } }); return true; }; // Cleanup function for graceful shutdown const cleanup = async (): Promise => { try { // Close all WebSocket connections connectedClients.forEach((clients) => { clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.terminate(); } }); }); logger.info("WebSocket cleanup completed"); } catch (error) { logger.error("Error during WebSocket cleanup:", error); } }; export { router, handleWSUpgrade, sendToClient, broadcastToAllExcept, connectedClients, hasActiveConnections, getActiveNodes, disconnectClient, NODE_ID, cleanup, getClientConfigVersion }; ================================================ FILE: server/setup/.gitignore ================================================ migrations.ts ================================================ FILE: server/setup/clearStaleData.ts ================================================ import { build } from "@server/build"; import { db, deviceWebAuthCodes, sessionTransferToken } from "@server/db"; import { emailVerificationCodes, newtSessions, passwordResetTokens, resourceAccessToken, resourceOtp, resourceSessions, sessions, userInvites } from "@server/db"; import logger from "@server/logger"; import { lt } from "drizzle-orm"; export async function clearStaleData() { try { await db .delete(sessions) .where(lt(sessions.expiresAt, new Date().getTime())); } catch (e) { logger.warn("Error clearing expired sessions:", e); } try { await db .delete(newtSessions) .where(lt(newtSessions.expiresAt, new Date().getTime())); } catch (e) { logger.warn("Error clearing expired newtSessions:", e); } try { await db .delete(emailVerificationCodes) .where(lt(emailVerificationCodes.expiresAt, new Date().getTime())); } catch (e) { logger.warn("Error clearing expired emailVerificationCodes:", e); } try { await db .delete(passwordResetTokens) .where(lt(passwordResetTokens.expiresAt, new Date().getTime())); } catch (e) { logger.warn("Error clearing expired passwordResetTokens:", e); } try { await db .delete(userInvites) .where(lt(userInvites.expiresAt, new Date().getTime())); } catch (e) { logger.warn("Error clearing expired userInvites:", e); } try { await db .delete(resourceAccessToken) .where(lt(resourceAccessToken.expiresAt, new Date().getTime())); } catch (e) { logger.warn("Error clearing expired resourceAccessToken:", e); } try { await db .delete(resourceSessions) .where(lt(resourceSessions.expiresAt, new Date().getTime())); } catch (e) { logger.warn("Error clearing expired resourceSessions:", e); } try { await db .delete(resourceOtp) .where(lt(resourceOtp.expiresAt, new Date().getTime())); } catch (e) { logger.warn("Error clearing expired resourceOtp:", e); } if (build !== "oss") { try { await db .delete(sessionTransferToken) .where( lt(sessionTransferToken.expiresAt, new Date().getTime()) ); } catch (e) { logger.warn("Error clearing expired sessionTransferToken:", e); } } try { await db .delete(deviceWebAuthCodes) .where(lt(deviceWebAuthCodes.expiresAt, new Date().getTime())); } catch (e) { logger.warn("Error clearing expired deviceWebAuthCodes:", e); } } ================================================ FILE: server/setup/copyInConfig.ts ================================================ import { db, dnsRecords } from "@server/db"; import { domains, exitNodes, orgDomains, orgs, resources } from "@server/db"; import config from "@server/lib/config"; import { eq, ne } from "drizzle-orm"; import { build } from "@server/build"; export async function copyInConfig() { if (build == "saas") { return; } const endpoint = config.getRawConfig().gerbil.base_endpoint; const listenPort = config.getRawConfig().gerbil.start_port; if ( !config.getRawConfig().flags?.disable_config_managed_domains && config.getRawConfig().domains ) { await copyInDomains(); } const exitNodeName = config.getRawConfig().gerbil.exit_node_name; if (exitNodeName) { await db .update(exitNodes) .set({ endpoint, listenPort }) .where(eq(exitNodes.name, exitNodeName)); } else { await db .update(exitNodes) .set({ endpoint }) .where(ne(exitNodes.endpoint, endpoint)); await db .update(exitNodes) .set({ listenPort }) .where(ne(exitNodes.listenPort, listenPort)); } } async function copyInDomains() { await db.transaction(async (trx) => { const rawDomains = config.getRawConfig().domains!; // always defined if disable flag is not set const configDomains = Object.entries(rawDomains).map( ([key, value]) => ({ domainId: key, baseDomain: value.base_domain.toLowerCase(), certResolver: value.cert_resolver || null, preferWildcardCert: value.prefer_wildcard_cert || null }) ); const existingDomains = await trx .select() .from(domains) .where(eq(domains.configManaged, true)); const existingDomainKeys = new Set( existingDomains.map((d) => d.domainId) ); const configDomainKeys = new Set(configDomains.map((d) => d.domainId)); for (const existingDomain of existingDomains) { if (!configDomainKeys.has(existingDomain.domainId)) { await trx .delete(domains) .where(eq(domains.domainId, existingDomain.domainId)); await trx .delete(dnsRecords) .where(eq(dnsRecords.domainId, existingDomain.domainId)); } } for (const { domainId, baseDomain, certResolver, preferWildcardCert } of configDomains) { if (existingDomainKeys.has(domainId)) { await trx .update(domains) .set({ baseDomain, verified: true, type: "wildcard", certResolver, preferWildcardCert }) .where(eq(domains.domainId, domainId)); // delete the dns records and add them again to ensure they are correct await trx .delete(dnsRecords) .where(eq(dnsRecords.domainId, domainId)); await trx.insert(dnsRecords).values([ { domainId, recordType: "A", baseDomain, value: "Server IP Address", verified: true }, { domainId, recordType: "A", baseDomain: `*.${baseDomain}`, value: "Server IP Address", verified: true } ]); } else { await trx.insert(domains).values({ domainId, baseDomain, configManaged: true, type: "wildcard", verified: true, certResolver, preferWildcardCert }); await trx.insert(dnsRecords).values([ { domainId, recordType: "A", baseDomain, value: "Server IP Address", verified: true }, { domainId, recordType: "A", baseDomain: `*.${baseDomain}`, value: "Server IP Address", verified: true } ]); } } const allOrgs = await trx.select().from(orgs); const existingOrgDomains = await trx.select().from(orgDomains); const existingOrgDomainSet = new Set( existingOrgDomains.map((od) => `${od.orgId}-${od.domainId}`) ); const newOrgDomains = []; for (const org of allOrgs) { for (const domain of configDomains) { const key = `${org.orgId}-${domain.domainId}`; if (!existingOrgDomainSet.has(key)) { newOrgDomains.push({ orgId: org.orgId, domainId: domain.domainId }); } } } if (newOrgDomains.length > 0) { await trx.insert(orgDomains).values(newOrgDomains).execute(); } }); await db.transaction(async (trx) => { const allResources = await trx .select() .from(resources) .leftJoin(domains, eq(domains.domainId, resources.domainId)); for (const { resources: resource, domains: domain } of allResources) { if (!resource || !domain) { continue; } if (!domain.configManaged) { continue; } let fullDomain = ""; if (!resource.subdomain) { fullDomain = domain.baseDomain; } else { fullDomain = `${resource.subdomain}.${domain.baseDomain}`; } await trx .update(resources) .set({ fullDomain }) .where(eq(resources.resourceId, resource.resourceId)); } }); } ================================================ FILE: server/setup/ensureActions.ts ================================================ import { ActionsEnum } from "@server/auth/actions"; import { db, orgs } from "@server/db"; import { actions, roles, roleActions } from "@server/db"; import { eq, inArray } from "drizzle-orm"; import logger from "@server/logger"; export async function ensureActions() { await db.transaction(async (trx) => { const actionIds = Object.values(ActionsEnum); const existingActions = await trx.select().from(actions).execute(); const existingActionIds = existingActions.map( (action) => action.actionId ); const actionsToAdd = actionIds.filter( (id) => !existingActionIds.includes(id) ); const actionsToRemove = existingActionIds.filter( (id) => !actionIds.includes(id as ActionsEnum) ); const defaultRoles = await trx .select() .from(roles) .where(eq(roles.isAdmin, true)) .execute(); const allOrgs = await trx .select({ orgId: orgs.orgId }) .from(orgs) .execute(); const allOrgIds = new Set(allOrgs.map((o) => o.orgId)); const validRoles = defaultRoles.filter( (r) => r.orgId && r.roleId && allOrgIds.has(r.orgId) ); const skipped = defaultRoles.length - validRoles.length; if (skipped > 0) { logger.warn(`Skipped ${skipped} orphaned admin roles missing orgs`); } // Add new actions for (const actionId of actionsToAdd) { logger.debug(`Adding action: ${actionId}`); await trx.insert(actions).values({ actionId }).execute(); // Add new actions to the Default role if (validRoles.length != 0) { await trx .insert(roleActions) .values( validRoles.map((role) => ({ roleId: role.roleId, actionId, orgId: role.orgId })) ) .execute(); } } // Remove deprecated actions if (actionsToRemove.length > 0) { logger.debug(`Removing actions: ${actionsToRemove.join(", ")}`); await trx .delete(roleActions) .where(inArray(roleActions.actionId, actionsToRemove)) .execute(); await trx .delete(actions) .where(inArray(actions.actionId, actionsToRemove)) .execute(); } }); } ================================================ FILE: server/setup/ensureSetupToken.ts ================================================ import { db, setupTokens, users } from "@server/db"; import { eq } from "drizzle-orm"; import { generateRandomString, RandomReader } from "@oslojs/crypto/random"; import moment from "moment"; import logger from "@server/logger"; const random: RandomReader = { read(bytes: Uint8Array): void { crypto.getRandomValues(bytes); } }; function generateToken(): string { // Generate a 32-character alphanumeric token const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; return generateRandomString(random, alphabet, 32); } function validateToken(token: string): boolean { const tokenRegex = /^[a-z0-9]{32}$/; return tokenRegex.test(token); } function generateId(length: number): string { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; return generateRandomString(random, alphabet, length); } function showSetupToken(token: string, source: string): void { console.log(`=== SETUP TOKEN ${source} ===`); console.log("Token:", token); console.log("Use this token on the initial setup page"); console.log("================================"); } export async function ensureSetupToken() { try { // Check if a server admin already exists const [existingAdmin] = await db .select() .from(users) .where(eq(users.serverAdmin, true)); // If admin exists, no need for setup token if (existingAdmin) { logger.debug( "Server admin exists. Setup token generation skipped." ); return; } // Check if a setup token already exists const [existingToken] = await db .select() .from(setupTokens) .where(eq(setupTokens.used, false)); const envSetupToken = process.env.PANGOLIN_SETUP_TOKEN; // console.debug("PANGOLIN_SETUP_TOKEN:", envSetupToken); if (envSetupToken) { if (!validateToken(envSetupToken)) { throw new Error( "invalid token format for PANGOLIN_SETUP_TOKEN" ); } if (existingToken) { // Token exists in DB - update it if different if (existingToken.token !== envSetupToken) { console.warn( "Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set" ); await db .update(setupTokens) .set({ token: envSetupToken }) .where(eq(setupTokens.tokenId, existingToken.tokenId)); } } else { // No existing token - insert new one const tokenId = generateId(15); await db.insert(setupTokens).values({ tokenId: tokenId, token: envSetupToken, used: false, dateCreated: moment().toISOString(), dateUsed: null }); } showSetupToken(envSetupToken, "FROM ENVIRONMENT"); return; } // If unused token exists, display it instead of creating a new one if (existingToken) { showSetupToken(existingToken.token, "EXISTS"); return; } // Generate a new setup token const token = generateToken(); const tokenId = generateId(15); await db.insert(setupTokens).values({ tokenId: tokenId, token: token, used: false, dateCreated: moment().toISOString(), dateUsed: null }); showSetupToken(token, "GENERATED"); } catch (error) { console.error("Failed to ensure setup token:", error); throw error; } } ================================================ FILE: server/setup/index.ts ================================================ import { ensureActions } from "./ensureActions"; import { copyInConfig } from "./copyInConfig"; import { clearStaleData } from "./clearStaleData"; import { ensureSetupToken } from "./ensureSetupToken"; export async function runSetupFunctions() { await copyInConfig(); // copy in the config to the db as needed await ensureActions(); // make sure all of the actions are in the db and the roles await clearStaleData(); await ensureSetupToken(); // ensure setup token exists for initial setup } ================================================ FILE: server/setup/migrationsPg.ts ================================================ #! /usr/bin/env node import { migrate } from "drizzle-orm/node-postgres/migrator"; import { db } from "../db/pg"; import semver from "semver"; import { versionMigrations } from "../db/pg"; import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; import path from "path"; import { build } from "@server/build"; import m1 from "./scriptsPg/1.6.0"; import m2 from "./scriptsPg/1.7.0"; import m3 from "./scriptsPg/1.8.0"; import m4 from "./scriptsPg/1.9.0"; import m5 from "./scriptsPg/1.10.0"; import m6 from "./scriptsPg/1.10.2"; import m7 from "./scriptsPg/1.11.0"; import m8 from "./scriptsPg/1.11.1"; import m9 from "./scriptsPg/1.12.0"; import m10 from "./scriptsPg/1.13.0"; import m11 from "./scriptsPg/1.14.0"; import m12 from "./scriptsPg/1.15.0"; import m13 from "./scriptsPg/1.15.3"; import m14 from "./scriptsPg/1.15.4"; import m15 from "./scriptsPg/1.16.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA // Define the migration list with versions and their corresponding functions const migrations = [ { version: "1.6.0", run: m1 }, { version: "1.7.0", run: m2 }, { version: "1.8.0", run: m3 }, { version: "1.9.0", run: m4 }, { version: "1.10.0", run: m5 }, { version: "1.10.2", run: m6 }, { version: "1.11.0", run: m7 }, { version: "1.11.1", run: m8 }, { version: "1.12.0", run: m9 }, { version: "1.13.0", run: m10 }, { version: "1.14.0", run: m11 }, { version: "1.15.0", run: m12 }, { version: "1.15.3", run: m13 }, { version: "1.15.4", run: m14 }, { version: "1.16.0", run: m15 } // Add new migrations here as they are created ] as { version: string; run: () => Promise; }[]; await run(); async function run() { // run the migrations await runMigrations(); } export async function runMigrations() { if (build == "saas") { console.log("Running in SaaS mode, skipping migrations..."); return; } if (process.env.DISABLE_MIGRATIONS) { console.log("Migrations are disabled. Skipping..."); return; } try { const appVersion = APP_VERSION; // determine if the migrations table exists const exists = await db .select() .from(versionMigrations) .limit(1) .execute() .then((res) => res.length > 0) .catch(() => false); if (exists) { console.log("Migrations table exists, running scripts..."); await executeScripts(); } else { console.log("Migrations table does not exist, creating it..."); console.log("Running migrations..."); try { await migrate(db, { migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build }); console.log("Migrations completed successfully."); } catch (error) { console.error("Error running migrations:", error); } await db .insert(versionMigrations) .values({ version: appVersion, executedAt: Date.now() }) .execute(); } } catch (e) { console.error("Error running migrations:", e); await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60 * 24 * 1) ); } } async function executeScripts() { try { // Get the last executed version from the database const lastExecuted = await db.select().from(versionMigrations); // Filter and sort migrations const pendingMigrations = lastExecuted .map((m) => m) .sort((a, b) => semver.compare(b.version, a.version)); const startVersion = pendingMigrations[0]?.version ?? "0.0.0"; console.log(`Starting migrations from version ${startVersion}`); const migrationsToRun = migrations.filter((migration) => semver.gt(migration.version, startVersion) ); console.log( "Migrations to run:", migrationsToRun.map((m) => m.version).join(", ") ); // Run migrations in order for (const migration of migrationsToRun) { console.log(`Running migration ${migration.version}`); try { await migration.run(); // Update version in database await db .insert(versionMigrations) .values({ version: migration.version, executedAt: Date.now() }) .execute(); console.log( `Successfully completed migration ${migration.version}` ); } catch (e) { if ( e instanceof Error && typeof (e as any).code === "string" && (e as any).code === "23505" ) { console.error("Migration has already run! Skipping..."); continue; // or return, depending on context } console.error( `Failed to run migration ${migration.version}:`, e ); throw e; } } console.log("All migrations completed successfully"); } catch (error) { console.error("Migration process failed:", error); throw error; } } ================================================ FILE: server/setup/migrationsSqlite.ts ================================================ #! /usr/bin/env node import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { db, exists } from "../db/sqlite"; import path from "path"; import semver from "semver"; import { versionMigrations } from "../db/sqlite"; import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts"; import { SqliteError } from "better-sqlite3"; import fs from "fs"; import { build } from "@server/build"; import m1 from "./scriptsSqlite/1.0.0-beta1"; import m2 from "./scriptsSqlite/1.0.0-beta2"; import m3 from "./scriptsSqlite/1.0.0-beta3"; import m4 from "./scriptsSqlite/1.0.0-beta5"; import m5 from "./scriptsSqlite/1.0.0-beta6"; import m6 from "./scriptsSqlite/1.0.0-beta9"; import m7 from "./scriptsSqlite/1.0.0-beta10"; import m8 from "./scriptsSqlite/1.0.0-beta12"; import m13 from "./scriptsSqlite/1.0.0-beta13"; import m15 from "./scriptsSqlite/1.0.0-beta15"; import m16 from "./scriptsSqlite/1.0.0"; import m17 from "./scriptsSqlite/1.1.0"; import m18 from "./scriptsSqlite/1.2.0"; import m19 from "./scriptsSqlite/1.3.0"; import m20 from "./scriptsSqlite/1.5.0"; import m21 from "./scriptsSqlite/1.6.0"; import m22 from "./scriptsSqlite/1.7.0"; import m23 from "./scriptsSqlite/1.8.0"; import m24 from "./scriptsSqlite/1.9.0"; import m25 from "./scriptsSqlite/1.10.0"; import m26 from "./scriptsSqlite/1.10.1"; import m27 from "./scriptsSqlite/1.10.2"; import m28 from "./scriptsSqlite/1.11.0"; import m29 from "./scriptsSqlite/1.11.1"; import m30 from "./scriptsSqlite/1.12.0"; import m31 from "./scriptsSqlite/1.13.0"; import m32 from "./scriptsSqlite/1.14.0"; import m33 from "./scriptsSqlite/1.15.0"; import m34 from "./scriptsSqlite/1.15.3"; import m35 from "./scriptsSqlite/1.15.4"; import m36 from "./scriptsSqlite/1.16.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA // Define the migration list with versions and their corresponding functions const migrations = [ { version: "1.0.0-beta.1", run: m1 }, { version: "1.0.0-beta.2", run: m2 }, { version: "1.0.0-beta.3", run: m3 }, { version: "1.0.0-beta.5", run: m4 }, { version: "1.0.0-beta.6", run: m5 }, { version: "1.0.0-beta.9", run: m6 }, { version: "1.0.0-beta.10", run: m7 }, { version: "1.0.0-beta.12", run: m8 }, { version: "1.0.0-beta.13", run: m13 }, { version: "1.0.0-beta.15", run: m15 }, { version: "1.0.0", run: m16 }, { version: "1.1.0", run: m17 }, { version: "1.2.0", run: m18 }, { version: "1.3.0", run: m19 }, { version: "1.5.0", run: m20 }, { version: "1.6.0", run: m21 }, { version: "1.7.0", run: m22 }, { version: "1.8.0", run: m23 }, { version: "1.9.0", run: m24 }, { version: "1.10.0", run: m25 }, { version: "1.10.1", run: m26 }, { version: "1.10.2", run: m27 }, { version: "1.11.0", run: m28 }, { version: "1.11.1", run: m29 }, { version: "1.12.0", run: m30 }, { version: "1.13.0", run: m31 }, { version: "1.14.0", run: m32 }, { version: "1.15.0", run: m33 }, { version: "1.15.3", run: m34 }, { version: "1.15.4", run: m35 }, { version: "1.16.0", run: m36 } // Add new migrations here as they are created ] as const; await run(); async function run() { // run the migrations await runMigrations(); } function backupDb() { // make dir config/db/backups const appPath = APP_PATH; const dbDir = path.join(appPath, "db"); const backupsDir = path.join(dbDir, "backups"); // check if the backups directory exists and create it if it doesn't if (!fs.existsSync(backupsDir)) { fs.mkdirSync(backupsDir, { recursive: true }); } // copy the db.sqlite file to backups // add the date to the filename const date = new Date(); const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`; const dbPath = path.join(dbDir, "db.sqlite"); const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`); fs.copyFileSync(dbPath, backupPath); } export async function runMigrations() { if (build == "saas") { console.log("Running in SaaS mode, skipping migrations..."); return; } if (process.env.DISABLE_MIGRATIONS) { console.log("Migrations are disabled. Skipping..."); return; } try { const appVersion = APP_VERSION; if (exists) { await executeScripts(); } else { console.log("Running migrations..."); try { migrate(db, { migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build }); console.log("Migrations completed successfully."); } catch (error) { console.error("Error running migrations:", error); } await db .insert(versionMigrations) .values({ version: appVersion, executedAt: Date.now() }) .execute(); } } catch (e) { console.error("Error running migrations:", e); await new Promise((resolve) => setTimeout(resolve, 1000 * 60 * 60 * 24 * 1) ); } } async function executeScripts() { try { // Get the last executed version from the database const lastExecuted = await db.select().from(versionMigrations); // Filter and sort migrations const pendingMigrations = lastExecuted .map((m) => m) .sort((a, b) => semver.compare(b.version, a.version)); const startVersion = pendingMigrations[0]?.version ?? APP_VERSION; console.log(`Starting migrations from version ${startVersion}`); const migrationsToRun = migrations.filter((migration) => semver.gt(migration.version, startVersion) ); console.log( "Migrations to run:", migrationsToRun.map((m) => m.version).join(", ") ); // Run migrations in order for (const migration of migrationsToRun) { console.log(`Running migration ${migration.version}`); try { if (!process.env.DISABLE_BACKUP_ON_MIGRATION) { // Backup the database before running the migration backupDb(); } await migration.run(); // Update version in database await db .insert(versionMigrations) .values({ version: migration.version, executedAt: Date.now() }) .execute(); console.log( `Successfully completed migration ${migration.version}` ); } catch (e) { if ( e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE" ) { console.error("Migration has already run! Skipping..."); continue; } console.error( `Failed to run migration ${migration.version}:`, e ); throw e; // Re-throw to stop migration process } } console.log("All migrations completed successfully"); } catch (error) { console.error("Migration process failed:", error); throw error; } } ================================================ FILE: server/setup/scriptsPg/1.10.0.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; import { __DIRNAME, APP_PATH } from "@server/lib/consts"; import { readFileSync } from "fs"; import path, { join } from "path"; const version = "1.10.0"; export default async function migration() { console.log(`Running setup script ${version}...`); try { const resources = await db.execute(sql` SELECT "resourceId" FROM "resources" `); const siteResources = await db.execute(sql` SELECT "siteResourceId" FROM "siteResources" `); await db.execute(sql`BEGIN`); await db.execute( sql`ALTER TABLE "exitNodes" ADD COLUMN "region" text;` ); await db.execute( sql`ALTER TABLE "idpOidcConfig" ADD COLUMN "variant" text DEFAULT 'oidc' NOT NULL;` ); await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "niceId" text DEFAULT '' NOT NULL;` ); await db.execute( sql`ALTER TABLE "siteResources" ADD COLUMN "niceId" text DEFAULT '' NOT NULL;` ); await db.execute( sql`ALTER TABLE "userOrgs" ADD COLUMN "autoProvisioned" boolean DEFAULT false;` ); await db.execute( sql`ALTER TABLE "targets" ADD COLUMN "pathMatchType" text;` ); await db.execute(sql`ALTER TABLE "targets" ADD COLUMN "path" text;`); await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "headers" text;` ); const usedNiceIds: string[] = []; for (const resource of resources.rows) { // Generate a unique name and ensure it's unique let niceId = ""; let loops = 0; while (true) { if (loops > 100) { throw new Error("Could not generate a unique name"); } niceId = generateName(); if (!usedNiceIds.includes(niceId)) { usedNiceIds.push(niceId); break; } loops++; } await db.execute(sql` UPDATE "resources" SET "niceId" = ${niceId} WHERE "resourceId" = ${resource.resourceId} `); } for (const resource of siteResources.rows) { // Generate a unique name and ensure it's unique let niceId = ""; let loops = 0; while (true) { if (loops > 100) { throw new Error("Could not generate a unique name"); } niceId = generateName(); if (!usedNiceIds.includes(niceId)) { usedNiceIds.push(niceId); break; } loops++; } await db.execute(sql` UPDATE "siteResources" SET "niceId" = ${niceId} WHERE "siteResourceId" = ${resource.siteResourceId} `); } // Handle auto-provisioned users for identity providers const autoProvisionIdps = await db.execute(sql` SELECT "idpId" FROM "idp" WHERE "autoProvision" = true `); for (const idp of autoProvisionIdps.rows) { // Get all users with this identity provider const usersWithIdp = await db.execute(sql` SELECT "id" FROM "user" WHERE "idpId" = ${idp.idpId} `); // Update userOrgs to set autoProvisioned to true for these users for (const user of usersWithIdp.rows) { await db.execute(sql` UPDATE "userOrgs" SET "autoProvisioned" = true WHERE "userId" = ${user.id} `); } } await db.execute(sql`COMMIT`); console.log(`Migrated database`); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Failed to migrate db:", e); throw e; } } const dev = process.env.ENVIRONMENT !== "prod"; let file; if (!dev) { file = join(__DIRNAME, "names.json"); } else { file = join("server/db/names.json"); } export const names = JSON.parse(readFileSync(file, "utf-8")); export function generateName(): string { const name = ( names.descriptors[ Math.floor(Math.random() * names.descriptors.length) ] + "-" + names.animals[Math.floor(Math.random() * names.animals.length)] ) .toLowerCase() .replace(/\s/g, "-"); // clean out any non-alphanumeric characters except for dashes return name.replace(/[^a-z0-9-]/g, ""); } ================================================ FILE: server/setup/scriptsPg/1.10.2.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; import { __DIRNAME, APP_PATH } from "@server/lib/consts"; const version = "1.10.2"; export default async function migration() { console.log(`Running setup script ${version}...`); try { const resources = await db.execute(sql` SELECT * FROM "resources" `); await db.execute(sql`BEGIN`); for (const resource of resources.rows) { const headers = resource.headers as string | null; if (headers && headers !== "") { // lets convert it to json // fist split at commas const headersArray = headers .split(",") .map((header: string) => { const [name, ...valueParts] = header.split(":"); const value = valueParts.join(":").trim(); return { name: name.trim(), value }; }); await db.execute(sql` UPDATE "resources" SET "headers" = ${JSON.stringify(headersArray)} WHERE "resourceId" = ${resource.resourceId} `); console.log( `Updated resource ${resource.resourceId} headers to JSON format` ); } } await db.execute(sql`COMMIT`); console.log(`Migrated database`); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Failed to migrate db:", e); throw e; } } ================================================ FILE: server/setup/scriptsPg/1.11.0.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { randomUUID } from "crypto"; const version = "1.11.0"; export default async function migration() { console.log(`Running setup script ${version}...`); try { await db.execute(sql`BEGIN`); await db.execute(sql` CREATE TABLE "account" ( "accountId" serial PRIMARY KEY NOT NULL, "userId" varchar NOT NULL ); `); await db.execute(sql` CREATE TABLE "accountDomains" ( "accountId" integer NOT NULL, "domainId" varchar NOT NULL ); `); await db.execute(sql` CREATE TABLE "certificates" ( "certId" serial PRIMARY KEY NOT NULL, "domain" varchar(255) NOT NULL, "domainId" varchar, "wildcard" boolean DEFAULT false, "status" varchar(50) DEFAULT 'pending' NOT NULL, "expiresAt" bigint, "lastRenewalAttempt" bigint, "createdAt" bigint NOT NULL, "updatedAt" bigint NOT NULL, "orderId" varchar(500), "errorMessage" text, "renewalCount" integer DEFAULT 0, "certFile" text, "keyFile" text, CONSTRAINT "certificates_domain_unique" UNIQUE("domain") ); `); await db.execute(sql` CREATE TABLE "customers" ( "customerId" varchar(255) PRIMARY KEY NOT NULL, "orgId" varchar(255) NOT NULL, "email" varchar(255), "name" varchar(255), "phone" varchar(50), "address" text, "createdAt" bigint NOT NULL, "updatedAt" bigint NOT NULL ); `); await db.execute(sql` CREATE TABLE "dnsChallenges" ( "dnsChallengeId" serial PRIMARY KEY NOT NULL, "domain" varchar(255) NOT NULL, "token" varchar(255) NOT NULL, "keyAuthorization" varchar(1000) NOT NULL, "createdAt" bigint NOT NULL, "expiresAt" bigint NOT NULL, "completed" boolean DEFAULT false ); `); await db.execute(sql` CREATE TABLE "domainNamespaces" ( "domainNamespaceId" varchar(255) PRIMARY KEY NOT NULL, "domainId" varchar NOT NULL ); `); await db.execute(sql` CREATE TABLE "exitNodeOrgs" ( "exitNodeId" integer NOT NULL, "orgId" text NOT NULL ); `); await db.execute(sql` CREATE TABLE "limits" ( "limitId" varchar(255) PRIMARY KEY NOT NULL, "featureId" varchar(255) NOT NULL, "orgId" varchar NOT NULL, "value" real, "description" text ); `); await db.execute(sql` CREATE TABLE "loginPage" ( "loginPageId" serial PRIMARY KEY NOT NULL, "subdomain" varchar, "fullDomain" varchar, "exitNodeId" integer, "domainId" varchar ); `); await db.execute(sql` CREATE TABLE "loginPageOrg" ( "loginPageId" integer NOT NULL, "orgId" varchar NOT NULL ); `); await db.execute(sql` CREATE TABLE "remoteExitNodeSession" ( "id" varchar PRIMARY KEY NOT NULL, "remoteExitNodeId" varchar NOT NULL, "expiresAt" bigint NOT NULL ); `); await db.execute(sql` CREATE TABLE "remoteExitNode" ( "id" varchar PRIMARY KEY NOT NULL, "secretHash" varchar NOT NULL, "dateCreated" varchar NOT NULL, "version" varchar, "exitNodeId" integer ); `); await db.execute(sql` CREATE TABLE "sessionTransferToken" ( "token" varchar PRIMARY KEY NOT NULL, "sessionId" varchar NOT NULL, "encryptedSession" text NOT NULL, "expiresAt" bigint NOT NULL ); `); await db.execute(sql` CREATE TABLE "subscriptionItems" ( "subscriptionItemId" serial PRIMARY KEY NOT NULL, "subscriptionId" varchar(255) NOT NULL, "planId" varchar(255) NOT NULL, "priceId" varchar(255), "meterId" varchar(255), "unitAmount" real, "tiers" text, "interval" varchar(50), "currentPeriodStart" bigint, "currentPeriodEnd" bigint, "name" varchar(255) ); `); await db.execute(sql` CREATE TABLE "subscriptions" ( "subscriptionId" varchar(255) PRIMARY KEY NOT NULL, "customerId" varchar(255) NOT NULL, "status" varchar(50) DEFAULT 'active' NOT NULL, "canceledAt" bigint, "createdAt" bigint NOT NULL, "updatedAt" bigint, "billingCycleAnchor" bigint ); `); await db.execute(sql` CREATE TABLE "usage" ( "usageId" varchar(255) PRIMARY KEY NOT NULL, "featureId" varchar(255) NOT NULL, "orgId" varchar NOT NULL, "meterId" varchar(255), "instantaneousValue" real, "latestValue" real NOT NULL, "previousValue" real, "updatedAt" bigint NOT NULL, "rolledOverAt" bigint, "nextRolloverAt" bigint ); `); await db.execute(sql` CREATE TABLE "usageNotifications" ( "notificationId" serial PRIMARY KEY NOT NULL, "orgId" varchar NOT NULL, "featureId" varchar(255) NOT NULL, "limitId" varchar(255) NOT NULL, "notificationType" varchar(50) NOT NULL, "sentAt" bigint NOT NULL ); `); await db.execute(sql` CREATE TABLE "resourceHeaderAuth" ( "headerAuthId" serial PRIMARY KEY NOT NULL, "resourceId" integer NOT NULL, "headerAuthHash" varchar NOT NULL ); `); await db.execute(sql` CREATE TABLE "targetHealthCheck" ( "targetHealthCheckId" serial PRIMARY KEY NOT NULL, "targetId" integer NOT NULL, "hcEnabled" boolean DEFAULT false NOT NULL, "hcPath" varchar, "hcScheme" varchar, "hcMode" varchar DEFAULT 'http', "hcHostname" varchar, "hcPort" integer, "hcInterval" integer DEFAULT 30, "hcUnhealthyInterval" integer DEFAULT 30, "hcTimeout" integer DEFAULT 5, "hcHeaders" varchar, "hcFollowRedirects" boolean DEFAULT true, "hcMethod" varchar DEFAULT 'GET', "hcStatus" integer, "hcHealth" text DEFAULT 'unknown' ); `); await db.execute(sql`ALTER TABLE "orgs" ADD COLUMN "settings" text;`); await db.execute( sql`ALTER TABLE "targets" ADD COLUMN "rewritePath" text;` ); await db.execute( sql`ALTER TABLE "targets" ADD COLUMN "rewritePathType" text;` ); await db.execute( sql`ALTER TABLE "targets" ADD COLUMN "priority" integer DEFAULT 100 NOT NULL;` ); await db.execute( sql`ALTER TABLE "account" ADD CONSTRAINT "account_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "accountDomains" ADD CONSTRAINT "accountDomains_accountId_account_accountId_fk" FOREIGN KEY ("accountId") REFERENCES "public"."account"("accountId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "accountDomains" ADD CONSTRAINT "accountDomains_domainId_domains_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domains"("domainId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "certificates" ADD CONSTRAINT "certificates_domainId_domains_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domains"("domainId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "customers" ADD CONSTRAINT "customers_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "domainNamespaces" ADD CONSTRAINT "domainNamespaces_domainId_domains_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domains"("domainId") ON DELETE set null ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "exitNodeOrgs" ADD CONSTRAINT "exitNodeOrgs_exitNodeId_exitNodes_exitNodeId_fk" FOREIGN KEY ("exitNodeId") REFERENCES "public"."exitNodes"("exitNodeId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "exitNodeOrgs" ADD CONSTRAINT "exitNodeOrgs_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "limits" ADD CONSTRAINT "limits_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "loginPage" ADD CONSTRAINT "loginPage_exitNodeId_exitNodes_exitNodeId_fk" FOREIGN KEY ("exitNodeId") REFERENCES "public"."exitNodes"("exitNodeId") ON DELETE set null ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "loginPage" ADD CONSTRAINT "loginPage_domainId_domains_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domains"("domainId") ON DELETE set null ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "loginPageOrg" ADD CONSTRAINT "loginPageOrg_loginPageId_loginPage_loginPageId_fk" FOREIGN KEY ("loginPageId") REFERENCES "public"."loginPage"("loginPageId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "loginPageOrg" ADD CONSTRAINT "loginPageOrg_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "remoteExitNodeSession" ADD CONSTRAINT "remoteExitNodeSession_remoteExitNodeId_remoteExitNode_id_fk" FOREIGN KEY ("remoteExitNodeId") REFERENCES "public"."remoteExitNode"("id") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "remoteExitNode" ADD CONSTRAINT "remoteExitNode_exitNodeId_exitNodes_exitNodeId_fk" FOREIGN KEY ("exitNodeId") REFERENCES "public"."exitNodes"("exitNodeId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "sessionTransferToken" ADD CONSTRAINT "sessionTransferToken_sessionId_session_id_fk" FOREIGN KEY ("sessionId") REFERENCES "public"."session"("id") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "subscriptionItems" ADD CONSTRAINT "subscriptionItems_subscriptionId_subscriptions_subscriptionId_fk" FOREIGN KEY ("subscriptionId") REFERENCES "public"."subscriptions"("subscriptionId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_customerId_customers_customerId_fk" FOREIGN KEY ("customerId") REFERENCES "public"."customers"("customerId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "usage" ADD CONSTRAINT "usage_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "usageNotifications" ADD CONSTRAINT "usageNotifications_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "resourceHeaderAuth" ADD CONSTRAINT "resourceHeaderAuth_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "targetHealthCheck" ADD CONSTRAINT "targetHealthCheck_targetId_targets_targetId_fk" FOREIGN KEY ("targetId") REFERENCES "public"."targets"("targetId") ON DELETE cascade ON UPDATE no action;` ); const webauthnCredentialsQuery = await db.execute( sql`SELECT "credentialId", "publicKey", "userId", "signCount", "transports", "name", "lastUsed", "dateCreated" FROM "webauthnCredentials"` ); const webauthnCredentials = webauthnCredentialsQuery.rows as { credentialId: string; publicKey: string; userId: string; signCount: number; transports: string | null; name: string | null; lastUsed: string; dateCreated: string; }[]; // Delete the old record await db.execute(sql` DELETE FROM "webauthnCredentials"; `); for (const webauthnCredential of webauthnCredentials) { const newCredentialId = isoBase64URL.fromBuffer( new Uint8Array( Buffer.from(webauthnCredential.credentialId, "base64") ) ); const newPublicKey = isoBase64URL.fromBuffer( new Uint8Array( Buffer.from(webauthnCredential.publicKey, "base64") ) ); // Insert the updated record with converted values await db.execute(sql` INSERT INTO "webauthnCredentials" ("credentialId", "publicKey", "userId", "signCount", "transports", "name", "lastUsed", "dateCreated") VALUES (${newCredentialId}, ${newPublicKey}, ${webauthnCredential.userId}, ${webauthnCredential.signCount}, ${webauthnCredential.transports}, ${webauthnCredential.name}, ${webauthnCredential.lastUsed}, ${webauthnCredential.dateCreated}) `); } // 1. Add the column with placeholder so NOT NULL is satisfied await db.execute(sql` ALTER TABLE "resources" ADD COLUMN IF NOT EXISTS "resourceGuid" varchar(36) NOT NULL DEFAULT 'PLACEHOLDER' `); // 2. Fetch every row to backfill UUIDs const rows = await db.execute( sql`SELECT "resourceId" FROM "resources" WHERE "resourceGuid" = 'PLACEHOLDER'` ); const resources = rows.rows as { resourceId: number }[]; for (const r of resources) { await db.execute(sql` UPDATE "resources" SET "resourceGuid" = ${randomUUID()} WHERE "resourceId" = ${r.resourceId} `); } // get all of the targets const targetsQuery = await db.execute( sql`SELECT "targetId" FROM "targets"` ); const targets = targetsQuery.rows as { targetId: number; }[]; for (const target of targets) { await db.execute(sql` INSERT INTO "targetHealthCheck" ("targetId") VALUES (${target.targetId}) `); } // 3. Add UNIQUE constraint now that values are filled await db.execute(sql` ALTER TABLE "resources" ADD CONSTRAINT "resources_resourceGuid_unique" UNIQUE("resourceGuid") `); await db.execute(sql`COMMIT`); console.log(`Updated credentialId and publicKey`); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Unable to update credentialId and publicKey"); console.log(e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.11.1.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; const version = "1.11.1"; export default async function migration() { console.log(`Running setup script ${version}...`); try { await db.execute(sql`BEGIN`); await db.execute(sql`UPDATE "exitNodes" SET "online" = true`); // Mark exit nodes as online await db.execute(sql`COMMIT`); console.log(`Updated sites with exit node`); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Unable to update sites with exit node"); console.log(e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.12.0.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; const version = "1.12.0"; export default async function migration() { console.log(`Running setup script ${version}...`); try { await db.execute(sql`BEGIN`); await db.execute( sql`UPDATE "resourceRules" SET "match" = 'COUNTRY' WHERE "match" = 'GEOIP'` ); await db.execute(sql` CREATE TABLE "accessAuditLog" ( "id" serial PRIMARY KEY NOT NULL, "timestamp" bigint NOT NULL, "orgId" varchar NOT NULL, "actorType" varchar(50), "actor" varchar(255), "actorId" varchar(255), "resourceId" integer, "ip" varchar(45), "type" varchar(100) NOT NULL, "action" boolean NOT NULL, "location" text, "userAgent" text, "metadata" text ); `); await db.execute(sql` CREATE TABLE "actionAuditLog" ( "id" serial PRIMARY KEY NOT NULL, "timestamp" bigint NOT NULL, "orgId" varchar NOT NULL, "actorType" varchar(50) NOT NULL, "actor" varchar(255) NOT NULL, "actorId" varchar(255) NOT NULL, "action" varchar(100) NOT NULL, "metadata" text ); `); await db.execute(sql` CREATE TABLE "dnsRecords" ( "id" serial PRIMARY KEY NOT NULL, "domainId" varchar NOT NULL, "recordType" varchar NOT NULL, "baseDomain" varchar, "value" varchar NOT NULL, "verified" boolean DEFAULT false NOT NULL ); `); await db.execute(sql` CREATE TABLE "requestAuditLog" ( "id" serial PRIMARY KEY NOT NULL, "timestamp" integer NOT NULL, "orgId" text, "action" boolean NOT NULL, "reason" integer NOT NULL, "actorType" text, "actor" text, "actorId" text, "resourceId" integer, "ip" text, "location" text, "userAgent" text, "metadata" text, "headers" text, "query" text, "originalRequestURL" text, "scheme" text, "host" text, "path" text, "method" text, "tls" boolean ); `); await db.execute(sql` CREATE TABLE "blueprints" ( "blueprintId" serial PRIMARY KEY NOT NULL, "orgId" text NOT NULL, "name" varchar NOT NULL, "source" varchar NOT NULL, "createdAt" integer NOT NULL, "succeeded" boolean NOT NULL, "contents" text NOT NULL, "message" text ); `); await db.execute( sql`ALTER TABLE "blueprints" ADD CONSTRAINT "blueprints_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "remoteExitNode" ADD COLUMN "secondaryVersion" varchar;` ); await db.execute( sql`ALTER TABLE "resources" DROP CONSTRAINT "resources_skipToIdpId_idp_idpId_fk";` ); await db.execute( sql`ALTER TABLE "domains" ADD COLUMN "certResolver" varchar;` ); await db.execute( sql`ALTER TABLE "domains" ADD COLUMN "customCertResolver" varchar;` ); await db.execute( sql`ALTER TABLE "domains" ADD COLUMN "preferWildcardCert" boolean;` ); await db.execute( sql`ALTER TABLE "orgs" ADD COLUMN "requireTwoFactor" boolean;` ); await db.execute( sql`ALTER TABLE "orgs" ADD COLUMN "maxSessionLengthHours" integer;` ); await db.execute( sql`ALTER TABLE "orgs" ADD COLUMN "passwordExpiryDays" integer;` ); await db.execute( sql`ALTER TABLE "orgs" ADD COLUMN "settingsLogRetentionDaysRequest" integer DEFAULT 7 NOT NULL;` ); await db.execute( sql`ALTER TABLE "orgs" ADD COLUMN "settingsLogRetentionDaysAccess" integer DEFAULT 0 NOT NULL;` ); await db.execute( sql`ALTER TABLE "orgs" ADD COLUMN "settingsLogRetentionDaysAction" integer DEFAULT 0 NOT NULL;` ); await db.execute( sql`ALTER TABLE "resourceSessions" ADD COLUMN "issuedAt" bigint;` ); await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "proxyProtocol" boolean DEFAULT false NOT NULL;` ); await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "proxyProtocolVersion" integer DEFAULT 1;` ); await db.execute( sql`ALTER TABLE "session" ADD COLUMN "issuedAt" bigint;` ); await db.execute( sql`ALTER TABLE "user" ADD COLUMN "lastPasswordChange" bigint;` ); await db.execute( sql`ALTER TABLE "accessAuditLog" ADD CONSTRAINT "accessAuditLog_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "actionAuditLog" ADD CONSTRAINT "actionAuditLog_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "dnsRecords" ADD CONSTRAINT "dnsRecords_domainId_domains_domainId_fk" FOREIGN KEY ("domainId") REFERENCES "public"."domains"("domainId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "requestAuditLog" ADD CONSTRAINT "requestAuditLog_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`CREATE INDEX "idx_identityAuditLog_timestamp" ON "accessAuditLog" USING btree ("timestamp");` ); await db.execute( sql`CREATE INDEX "idx_identityAuditLog_org_timestamp" ON "accessAuditLog" USING btree ("orgId","timestamp");` ); await db.execute( sql`CREATE INDEX "idx_actionAuditLog_timestamp" ON "actionAuditLog" USING btree ("timestamp");` ); await db.execute( sql`CREATE INDEX "idx_actionAuditLog_org_timestamp" ON "actionAuditLog" USING btree ("orgId","timestamp");` ); await db.execute( sql`CREATE INDEX "idx_requestAuditLog_timestamp" ON "requestAuditLog" USING btree ("timestamp");` ); await db.execute( sql`CREATE INDEX "idx_requestAuditLog_org_timestamp" ON "requestAuditLog" USING btree ("orgId","timestamp");` ); await db.execute( sql`ALTER TABLE "resources" ADD CONSTRAINT "resources_skipToIdpId_idp_idpId_fk" FOREIGN KEY ("skipToIdpId") REFERENCES "public"."idp"("idpId") ON DELETE set null ON UPDATE no action;` ); await db.execute(sql`ALTER TABLE "orgs" DROP COLUMN "settings";`); // get all of the domains const domainsQuery = await db.execute( sql`SELECT "domainId", "baseDomain" FROM "domains"` ); const domains = domainsQuery.rows as { domainId: string; baseDomain: string; }[]; for (const domain of domains) { // insert two records into the dnsRecords table for each domain await db.execute(sql` INSERT INTO "dnsRecords" ("domainId", "recordType", "baseDomain", "value", "verified") VALUES (${domain.domainId}, 'A', ${`*.${domain.baseDomain}`}, ${"Server IP Address"}, true) `); await db.execute(sql` INSERT INTO "dnsRecords" ("domainId", "recordType", "baseDomain", "value", "verified") VALUES (${domain.domainId}, 'A', ${domain.baseDomain}, ${"Server IP Address"}, true) `); } await db.execute(sql`COMMIT`); console.log("Migrated database"); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Unable to migrate database"); console.log(e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.13.0.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; import { __DIRNAME } from "@server/lib/consts"; import { readFileSync } from "fs"; import { join } from "path"; const version = "1.13.0"; const dev = process.env.ENVIRONMENT !== "prod"; let file; if (!dev) { file = join(__DIRNAME, "names.json"); } else { file = join("server/db/names.json"); } export const names = JSON.parse(readFileSync(file, "utf-8")); export function generateName(): string { const name = ( names.descriptors[ Math.floor(Math.random() * names.descriptors.length) ] + "-" + names.animals[Math.floor(Math.random() * names.animals.length)] ) .toLowerCase() .replace(/\s/g, "-"); // clean out any non-alphanumeric characters except for dashes return name.replace(/[^a-z0-9-]/g, ""); } export default async function migration() { console.log(`Running setup script ${version}...`); try { await db.execute(sql`BEGIN`); await db.execute(sql` CREATE TABLE "clientSiteResources" ( "clientId" integer NOT NULL, "siteResourceId" integer NOT NULL ); `); await db.execute(sql` CREATE TABLE "clientSiteResourcesAssociationsCache" ( "clientId" integer NOT NULL, "siteResourceId" integer NOT NULL ); `); await db.execute(sql` CREATE TABLE "deviceWebAuthCodes" ( "codeId" serial PRIMARY KEY NOT NULL, "code" text NOT NULL, "ip" text, "city" text, "deviceName" text, "applicationName" text NOT NULL, "expiresAt" bigint NOT NULL, "createdAt" bigint NOT NULL, "verified" boolean DEFAULT false NOT NULL, "userId" varchar, CONSTRAINT "deviceWebAuthCodes_code_unique" UNIQUE("code") ); `); await db.execute(sql` CREATE TABLE "roleSiteResources" ( "roleId" integer NOT NULL, "siteResourceId" integer NOT NULL ); `); await db.execute(sql` CREATE TABLE "userSiteResources" ( "userId" varchar NOT NULL, "siteResourceId" integer NOT NULL ); `); await db.execute( sql`ALTER TABLE "clientSites" RENAME TO "clientSitesAssociationsCache";` ); await db.execute( sql`ALTER TABLE "clients" RENAME COLUMN "id" TO "clientId";` ); await db.execute( sql`ALTER TABLE "siteResources" RENAME COLUMN "destinationIp" TO "destination";` ); await db.execute( sql`ALTER TABLE "clientSitesAssociationsCache" DROP CONSTRAINT "clientSites_clientId_clients_id_fk";` ); await db.execute( sql`ALTER TABLE "clientSitesAssociationsCache" DROP CONSTRAINT "clientSites_siteId_sites_siteId_fk";` ); await db.execute( sql`ALTER TABLE "olms" DROP CONSTRAINT "olms_clientId_clients_id_fk";` ); await db.execute( sql`ALTER TABLE "roleClients" DROP CONSTRAINT "roleClients_clientId_clients_id_fk";` ); await db.execute( sql`ALTER TABLE "userClients" DROP CONSTRAINT "userClients_clientId_clients_id_fk";` ); await db.execute( sql`ALTER TABLE "siteResources" ALTER COLUMN "protocol" DROP NOT NULL;` ); await db.execute( sql`ALTER TABLE "siteResources" ALTER COLUMN "proxyPort" DROP NOT NULL;` ); await db.execute( sql`ALTER TABLE "siteResources" ALTER COLUMN "destinationPort" DROP NOT NULL;` ); await db.execute( sql`ALTER TABLE "clientSitesAssociationsCache" ADD COLUMN "publicKey" varchar;` ); await db.execute(sql`ALTER TABLE "clients" ADD COLUMN "userId" text;`); await db.execute( sql`ALTER TABLE "clients" ADD COLUMN "niceId" varchar NOT NULL DEFAULT 'PLACEHOLDER';` ); await db.execute(sql`ALTER TABLE "clients" ADD COLUMN "olmId" text;`); await db.execute(sql`ALTER TABLE "olms" ADD COLUMN "agent" text;`); await db.execute(sql`ALTER TABLE "olms" ADD COLUMN "name" varchar;`); await db.execute(sql`ALTER TABLE "olms" ADD COLUMN "userId" text;`); await db.execute( sql`ALTER TABLE "orgs" ADD COLUMN "utilitySubnet" varchar;` ); await db.execute( sql`ALTER TABLE "session" ADD COLUMN "deviceAuthUsed" boolean DEFAULT false NOT NULL;` ); await db.execute( sql`ALTER TABLE "siteResources" ADD COLUMN "mode" varchar NOT NULL DEFAULT 'host';` ); await db.execute( sql`ALTER TABLE "siteResources" ADD COLUMN "alias" varchar;` ); await db.execute( sql`ALTER TABLE "siteResources" ADD COLUMN "aliasAddress" varchar;` ); await db.execute( sql`ALTER TABLE "targetHealthCheck" ADD COLUMN "hcTlsServerName" text;` ); await db.execute( sql`ALTER TABLE "clientSiteResources" ADD CONSTRAINT "clientSiteResources_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "clientSiteResources" ADD CONSTRAINT "clientSiteResources_siteResourceId_siteResources_siteResourceId_fk" FOREIGN KEY ("siteResourceId") REFERENCES "public"."siteResources"("siteResourceId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "deviceWebAuthCodes" ADD CONSTRAINT "deviceWebAuthCodes_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "roleSiteResources" ADD CONSTRAINT "roleSiteResources_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "roleSiteResources" ADD CONSTRAINT "roleSiteResources_siteResourceId_siteResources_siteResourceId_fk" FOREIGN KEY ("siteResourceId") REFERENCES "public"."siteResources"("siteResourceId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "userSiteResources" ADD CONSTRAINT "userSiteResources_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "userSiteResources" ADD CONSTRAINT "userSiteResources_siteResourceId_siteResources_siteResourceId_fk" FOREIGN KEY ("siteResourceId") REFERENCES "public"."siteResources"("siteResourceId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "clients" ADD CONSTRAINT "clients_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "olms" ADD CONSTRAINT "olms_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE set null ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "olms" ADD CONSTRAINT "olms_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "roleClients" ADD CONSTRAINT "roleClients_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "userClients" ADD CONSTRAINT "userClients_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;` ); // set 100.96.128.0/24 as the utility subnet on all of the orgs await db.execute( sql`UPDATE "orgs" SET "utilitySubnet" = '100.96.128.0/24'` ); // Query all of the sites to get their remoteSubnets const sitesRemoteSubnetsData = await db.execute(sql`SELECT "siteId", "remoteSubnets" FROM "sites" WHERE "remoteSubnets" IS NOT NULL `); const sitesRemoteSubnets = sitesRemoteSubnetsData.rows as { siteId: number; remoteSubnets: string | null; }[]; await db.execute(sql`ALTER TABLE "sites" DROP COLUMN "remoteSubnets";`); // get all of the siteResources and set the the aliasAddress to 100.96.128.x starting at .8 const siteResourcesData = await db.execute( sql`SELECT "siteResourceId" FROM "siteResources" ORDER BY "siteResourceId" ASC` ); const siteResources = siteResourcesData.rows as { siteResourceId: number; }[]; let aliasIpOctet = 8; for (const siteResource of siteResources) { const aliasAddress = `100.96.128.${aliasIpOctet}`; await db.execute(sql` UPDATE "siteResources" SET "aliasAddress" = ${aliasAddress} WHERE "siteResourceId" = ${siteResource.siteResourceId} `); aliasIpOctet++; } // For each site with remote subnets we need to create a site resource of type cidr for each remote subnet for (const site of sitesRemoteSubnets) { if (site.remoteSubnets) { // Get the orgId for this site const siteDataQuery = await db.execute(sql` SELECT "orgId" FROM "sites" WHERE "siteId" = ${site.siteId} `); const siteData = siteDataQuery.rows[0] as | { orgId: string } | undefined; if (!siteData) continue; const subnets = site.remoteSubnets.split(","); for (const subnet of subnets) { const niceId = generateName(); await db.execute(sql` INSERT INTO "siteResources" ("siteId", "orgId", "niceId", "destination", "mode", "name") VALUES (${site.siteId}, ${siteData.orgId}, ${niceId}, ${subnet.trim()}, 'cidr', 'Remote Subnet'); `); } } } // Associate clients with site resources based on their previous site access // Get all client-site associations from the renamed clientSitesAssociationsCache table const clientSiteAssociationsQuery = await db.execute(sql` SELECT "clientId", "siteId" FROM "clientSitesAssociationsCache" `); const clientSiteAssociations = clientSiteAssociationsQuery.rows as { clientId: number; siteId: number; }[]; // For each client-site association, find all site resources for that site for (const association of clientSiteAssociations) { const siteResourcesQuery = await db.execute(sql` SELECT "siteResourceId" FROM "siteResources" WHERE "siteId" = ${association.siteId} `); const siteResources = siteResourcesQuery.rows as { siteResourceId: number; }[]; // Associate the client with all site resources from this site for (const siteResource of siteResources) { await db.execute(sql` INSERT INTO "clientSiteResources" ("clientId", "siteResourceId") VALUES (${association.clientId}, ${siteResource.siteResourceId}) `); // also associate in the clientSiteResourcesAssociationsCache table await db.execute(sql` INSERT INTO "clientSiteResourcesAssociationsCache" ("clientId", "siteResourceId") VALUES (${association.clientId}, ${siteResource.siteResourceId}) `); } } // Associate existing site resources with their org's admin role const siteResourcesWithOrgQuery = await db.execute(sql` SELECT "siteResourceId", "orgId" FROM "siteResources" `); const siteResourcesWithOrg = siteResourcesWithOrgQuery.rows as { siteResourceId: number; orgId: string; }[]; for (const siteResource of siteResourcesWithOrg) { const adminRoleQuery = await db.execute(sql` SELECT "roleId" FROM "roles" WHERE "orgId" = ${siteResource.orgId} AND "isAdmin" = true LIMIT 1 `); const adminRole = adminRoleQuery.rows[0] as | { roleId: number } | undefined; if (adminRole) { const existingQuery = await db.execute(sql` SELECT 1 FROM "roleSiteResources" WHERE "roleId" = ${adminRole.roleId} AND "siteResourceId" = ${siteResource.siteResourceId} LIMIT 1 `); if (existingQuery.rows.length === 0) { await db.execute(sql` INSERT INTO "roleSiteResources" ("roleId", "siteResourceId") VALUES (${adminRole.roleId}, ${siteResource.siteResourceId}) `); } } } // Populate niceId for clients const clientsQuery = await db.execute( sql`SELECT "clientId" FROM "clients"` ); const clients = clientsQuery.rows as { clientId: number; }[]; const usedNiceIds: string[] = []; for (const client of clients) { // Generate a unique name and ensure it's unique let niceId = ""; let loops = 0; while (true) { if (loops > 100) { throw new Error("Could not generate a unique name"); } niceId = generateName(); if (!usedNiceIds.includes(niceId)) { usedNiceIds.push(niceId); break; } loops++; } await db.execute(sql` UPDATE "clients" SET "niceId" = ${niceId} WHERE "clientId" = ${client.clientId} `); } await db.execute(sql`COMMIT`); console.log("Migrated database"); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Unable to migrate database"); console.log(e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.14.0.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; import { __DIRNAME } from "@server/lib/consts"; const version = "1.14.0"; export default async function migration() { console.log(`Running setup script ${version}...`); try { await db.execute(sql`BEGIN`); await db.execute(sql` CREATE TABLE "loginPageBranding" ( "loginPageBrandingId" serial PRIMARY KEY NOT NULL, "logoUrl" text NOT NULL, "logoWidth" integer NOT NULL, "logoHeight" integer NOT NULL, "primaryColor" text, "resourceTitle" text NOT NULL, "resourceSubtitle" text, "orgTitle" text, "orgSubtitle" text ); `); await db.execute(sql` CREATE TABLE "loginPageBrandingOrg" ( "loginPageBrandingId" integer NOT NULL, "orgId" varchar NOT NULL ); `); await db.execute(sql` CREATE TABLE "resourceHeaderAuthExtendedCompatibility" ( "headerAuthExtendedCompatibilityId" serial PRIMARY KEY NOT NULL, "resourceId" integer NOT NULL, "extendedCompatibilityIsActivated" boolean DEFAULT false NOT NULL ); `); await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "maintenanceModeEnabled" boolean DEFAULT false NOT NULL;` ); await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "maintenanceModeType" text DEFAULT 'forced';` ); await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "maintenanceTitle" text;` ); await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "maintenanceMessage" text;` ); await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "maintenanceEstimatedTime" text;` ); await db.execute( sql`ALTER TABLE "siteResources" ADD COLUMN "tcpPortRangeString" varchar NOT NULL DEFAULT '*';` ); await db.execute( sql`ALTER TABLE "siteResources" ADD COLUMN "udpPortRangeString" varchar NOT NULL DEFAULT '*';` ); await db.execute( sql`ALTER TABLE "siteResources" ADD COLUMN "disableIcmp" boolean DEFAULT false NOT NULL;` ); await db.execute( sql`ALTER TABLE "loginPageBrandingOrg" ADD CONSTRAINT "loginPageBrandingOrg_loginPageBrandingId_loginPageBranding_loginPageBrandingId_fk" FOREIGN KEY ("loginPageBrandingId") REFERENCES "public"."loginPageBranding"("loginPageBrandingId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "loginPageBrandingOrg" ADD CONSTRAINT "loginPageBrandingOrg_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "resourceHeaderAuthExtendedCompatibility" ADD CONSTRAINT "resourceHeaderAuthExtendedCompatibility_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;` ); await db.execute(sql`COMMIT`); console.log("Migrated database"); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Unable to migrate database"); console.log(e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.15.0.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; import { __DIRNAME } from "@server/lib/consts"; const version = "1.15.0"; export default async function migration() { console.log(`Running setup script ${version}...`); try { await db.execute(sql`BEGIN`); await db.execute(sql` CREATE TABLE "approvals" ( "approvalId" serial PRIMARY KEY NOT NULL, "timestamp" integer NOT NULL, "orgId" varchar NOT NULL, "clientId" integer, "userId" varchar NOT NULL, "decision" varchar DEFAULT 'pending' NOT NULL, "type" varchar NOT NULL ); `); await db.execute(sql` CREATE TABLE "clientPostureSnapshots" ( "snapshotId" serial PRIMARY KEY NOT NULL, "clientId" integer, "collectedAt" integer NOT NULL ); `); await db.execute(sql` CREATE TABLE "currentFingerprint" ( "id" serial PRIMARY KEY NOT NULL, "olmId" text NOT NULL, "firstSeen" integer NOT NULL, "lastSeen" integer NOT NULL, "lastCollectedAt" integer NOT NULL, "username" text, "hostname" text, "platform" text, "osVersion" text, "kernelVersion" text, "arch" text, "deviceModel" text, "serialNumber" text, "platformFingerprint" varchar, "biometricsEnabled" boolean DEFAULT false NOT NULL, "diskEncrypted" boolean DEFAULT false NOT NULL, "firewallEnabled" boolean DEFAULT false NOT NULL, "autoUpdatesEnabled" boolean DEFAULT false NOT NULL, "tpmAvailable" boolean DEFAULT false NOT NULL, "windowsAntivirusEnabled" boolean DEFAULT false NOT NULL, "macosSipEnabled" boolean DEFAULT false NOT NULL, "macosGatekeeperEnabled" boolean DEFAULT false NOT NULL, "macosFirewallStealthMode" boolean DEFAULT false NOT NULL, "linuxAppArmorEnabled" boolean DEFAULT false NOT NULL, "linuxSELinuxEnabled" boolean DEFAULT false NOT NULL ); `); await db.execute(sql` CREATE TABLE "fingerprintSnapshots" ( "id" serial PRIMARY KEY NOT NULL, "fingerprintId" integer, "username" text, "hostname" text, "platform" text, "osVersion" text, "kernelVersion" text, "arch" text, "deviceModel" text, "serialNumber" text, "platformFingerprint" varchar, "biometricsEnabled" boolean DEFAULT false NOT NULL, "diskEncrypted" boolean DEFAULT false NOT NULL, "firewallEnabled" boolean DEFAULT false NOT NULL, "autoUpdatesEnabled" boolean DEFAULT false NOT NULL, "tpmAvailable" boolean DEFAULT false NOT NULL, "windowsAntivirusEnabled" boolean DEFAULT false NOT NULL, "macosSipEnabled" boolean DEFAULT false NOT NULL, "macosGatekeeperEnabled" boolean DEFAULT false NOT NULL, "macosFirewallStealthMode" boolean DEFAULT false NOT NULL, "linuxAppArmorEnabled" boolean DEFAULT false NOT NULL, "linuxSELinuxEnabled" boolean DEFAULT false NOT NULL, "hash" text NOT NULL, "collectedAt" integer NOT NULL ); `); await db.execute( sql`ALTER TABLE "loginPageBranding" ALTER COLUMN "logoUrl" DROP NOT NULL;` ); await db.execute( sql`ALTER TABLE "clients" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;` ); await db.execute( sql`ALTER TABLE "clients" ADD COLUMN "blocked" boolean DEFAULT false NOT NULL;` ); await db.execute( sql`ALTER TABLE "clients" ADD COLUMN "approvalState" varchar;` ); await db.execute(sql`ALTER TABLE "idp" ADD COLUMN "tags" text;`); await db.execute( sql`ALTER TABLE "olms" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;` ); await db.execute( sql`ALTER TABLE "roles" ADD COLUMN "requireDeviceApproval" boolean DEFAULT false;` ); await db.execute( sql`ALTER TABLE "approvals" ADD CONSTRAINT "approvals_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "approvals" ADD CONSTRAINT "approvals_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "approvals" ADD CONSTRAINT "approvals_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "clientPostureSnapshots" ADD CONSTRAINT "clientPostureSnapshots_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "currentFingerprint" ADD CONSTRAINT "currentFingerprint_olmId_olms_id_fk" FOREIGN KEY ("olmId") REFERENCES "public"."olms"("id") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "fingerprintSnapshots" ADD CONSTRAINT "fingerprintSnapshots_fingerprintId_currentFingerprint_id_fk" FOREIGN KEY ("fingerprintId") REFERENCES "public"."currentFingerprint"("id") ON DELETE set null ON UPDATE no action;` ); await db.execute(sql`COMMIT`); console.log("Migrated database"); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Unable to migrate database"); console.log(e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.15.3.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; import { __DIRNAME } from "@server/lib/consts"; const version = "1.15.3"; export default async function migration() { console.log(`Running setup script ${version}...`); try { await db.execute(sql`BEGIN`); await db.execute( sql`ALTER TABLE "limits" ADD COLUMN "override" boolean DEFAULT false;` ); await db.execute( sql`ALTER TABLE "subscriptionItems" ADD COLUMN "stripeSubscriptionItemId" varchar(255);` ); await db.execute( sql`ALTER TABLE "subscriptionItems" ADD COLUMN "featureId" varchar(255);` ); await db.execute( sql`ALTER TABLE "subscriptions" ADD COLUMN "version" integer;` ); await db.execute( sql`ALTER TABLE "subscriptions" ADD COLUMN "type" varchar(50);` ); await db.execute(sql`COMMIT`); console.log("Migrated database"); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Unable to migrate database"); console.log(e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.15.4.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; import { __DIRNAME } from "@server/lib/consts"; const version = "1.15.4"; export default async function migration() { console.log(`Running setup script ${version}...`); try { await db.execute(sql`BEGIN`); await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "postAuthPath" text;` ); await db.execute(sql`COMMIT`); console.log("Migrated database"); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Unable to migrate database"); console.log(e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.16.0.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { encrypt } from "@server/lib/crypto"; import { generateCA } from "@server/lib/sshCA"; import fs from "fs"; import yaml from "js-yaml"; const version = "1.16.0"; function getServerSecret(): string { const envSecret = process.env.SERVER_SECRET; const configPath = fs.existsSync(configFilePath1) ? configFilePath1 : fs.existsSync(configFilePath2) ? configFilePath2 : null; // If no config file but an env secret is set, use the env secret directly if (!configPath) { if (envSecret && envSecret.length > 0) { return envSecret; } throw new Error( "Cannot generate org CA keys: no config file found and SERVER_SECRET env var is not set. " + "Expected config.yml or config.yaml in the config directory, or set SERVER_SECRET." ); } const configContent = fs.readFileSync(configPath, "utf8"); const config = yaml.load(configContent) as { server?: { secret?: string }; }; let secret = config?.server?.secret; if (!secret || secret.length === 0) { // Fall back to SERVER_SECRET env var if config does not contain server.secret if (envSecret && envSecret.length > 0) { secret = envSecret; } } if (!secret || secret.length === 0) { throw new Error( "Cannot generate org CA keys: no server.secret in config and SERVER_SECRET env var is not set. " + "Set server.secret in config.yml/config.yaml or set SERVER_SECRET." ); } return secret; } export default async function migration() { console.log(`Running setup script ${version}...`); // Ensure server secret exists before running migration (required for org CA key generation) getServerSecret(); try { await db.execute(sql`BEGIN`); // Schema changes await db.execute(sql` CREATE TABLE "roundTripMessageTracker" ( "messageId" serial PRIMARY KEY NOT NULL, "clientId" varchar, "messageType" varchar, "sentAt" bigint NOT NULL, "receivedAt" bigint, "error" text, "complete" boolean DEFAULT false NOT NULL ); `); await db.execute( sql`ALTER TABLE "orgs" ADD COLUMN "sshCaPrivateKey" text;` ); await db.execute( sql`ALTER TABLE "orgs" ADD COLUMN "sshCaPublicKey" text;` ); await db.execute( sql`ALTER TABLE "orgs" ADD COLUMN "isBillingOrg" boolean;` ); await db.execute( sql`ALTER TABLE "orgs" ADD COLUMN "billingOrgId" varchar;` ); await db.execute( sql`ALTER TABLE "roles" ADD COLUMN "sshSudoMode" varchar(32) DEFAULT 'none';` ); await db.execute( sql`ALTER TABLE "roles" ADD COLUMN "sshSudoCommands" text DEFAULT '[]';` ); await db.execute( sql`ALTER TABLE "roles" ADD COLUMN "sshCreateHomeDir" boolean DEFAULT true;` ); await db.execute( sql`ALTER TABLE "roles" ADD COLUMN "sshUnixGroups" text DEFAULT '[]';` ); await db.execute( sql`ALTER TABLE "siteResources" ADD COLUMN "authDaemonPort" integer DEFAULT 22123;` ); await db.execute( sql`ALTER TABLE "siteResources" ADD COLUMN "authDaemonMode" varchar(32) DEFAULT 'site';` ); await db.execute( sql`ALTER TABLE "userOrgs" ADD COLUMN "pamUsername" varchar;` ); // Set all admin role sudo to "full"; other roles keep default "none" await db.execute( sql`UPDATE "roles" SET "sshSudoMode" = 'full' WHERE "isAdmin" = true;` ); await db.execute(sql`COMMIT`); console.log("Migrated database"); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Unable to migrate database"); console.log(e); throw e; } // Generate and store encrypted SSH CA keys for all orgs try { const secret = getServerSecret(); const orgQuery = await db.execute(sql`SELECT "orgId" FROM "orgs"`); const orgRows = orgQuery.rows as { orgId: string }[]; const failedOrgIds: string[] = []; for (const row of orgRows) { try { const ca = generateCA(`pangolin-ssh-ca-${row.orgId}`); const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret); await db.execute(sql` UPDATE "orgs" SET "sshCaPrivateKey" = ${encryptedPrivateKey}, "sshCaPublicKey" = ${ca.publicKeyOpenSSH} WHERE "orgId" = ${row.orgId}; `); } catch (err) { failedOrgIds.push(row.orgId); console.error( `Error: No CA was generated for organization "${row.orgId}".`, err instanceof Error ? err.message : err ); } } if (orgRows.length > 0) { const succeeded = orgRows.length - failedOrgIds.length; console.log( `Generated and stored SSH CA keys for ${succeeded} org(s).` ); } if (failedOrgIds.length > 0) { console.error( `No CA was generated for ${failedOrgIds.length} organization(s): ${failedOrgIds.join( ", " )}` ); } } catch (e) { console.error( "Error while generating SSH CA keys for orgs after migration:", e ); } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.6.0.ts ================================================ import { db } from "@server/db/pg/driver"; import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { sql } from "drizzle-orm"; import fs from "fs"; import yaml from "js-yaml"; const version = "1.6.0"; export default async function migration() { console.log(`Running setup script ${version}...`); try { db.execute(sql`UPDATE 'user' SET email = LOWER(email);`); db.execute(sql`UPDATE 'user' SET username = LOWER(username);`); console.log(`Migrated database schema`); } catch (e) { console.log("Unable to make all usernames and emails lowercase"); console.log(e); } try { // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; if (rawConfig.server?.trust_proxy) { rawConfig.server.trust_proxy = 1; } // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); console.log(`Set trust_proxy to 1 in config file`); } catch (e) { console.log(`Unable to migrate config file. Error: ${e}`); } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.7.0.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; const version = "1.7.0"; export default async function migration() { console.log(`Running setup script ${version}...`); try { await db.execute(sql` BEGIN; CREATE TABLE "clientSites" ( "clientId" integer NOT NULL, "siteId" integer NOT NULL, "isRelayed" boolean DEFAULT false NOT NULL ); CREATE TABLE "clients" ( "id" serial PRIMARY KEY NOT NULL, "orgId" varchar NOT NULL, "exitNode" integer, "name" varchar NOT NULL, "pubKey" varchar, "subnet" varchar NOT NULL, "bytesIn" integer, "bytesOut" integer, "lastBandwidthUpdate" varchar, "lastPing" varchar, "type" varchar NOT NULL, "online" boolean DEFAULT false NOT NULL, "endpoint" varchar, "lastHolePunch" integer, "maxConnections" integer ); CREATE TABLE "clientSession" ( "id" varchar PRIMARY KEY NOT NULL, "olmId" varchar NOT NULL, "expiresAt" integer NOT NULL ); CREATE TABLE "olms" ( "id" varchar PRIMARY KEY NOT NULL, "secretHash" varchar NOT NULL, "dateCreated" varchar NOT NULL, "clientId" integer ); CREATE TABLE "roleClients" ( "roleId" integer NOT NULL, "clientId" integer NOT NULL ); CREATE TABLE "webauthnCredentials" ( "credentialId" varchar PRIMARY KEY NOT NULL, "userId" varchar NOT NULL, "publicKey" varchar NOT NULL, "signCount" integer NOT NULL, "transports" varchar, "name" varchar, "lastUsed" varchar NOT NULL, "dateCreated" varchar NOT NULL, "securityKeyName" varchar ); CREATE TABLE "userClients" ( "userId" varchar NOT NULL, "clientId" integer NOT NULL ); CREATE TABLE "webauthnChallenge" ( "sessionId" varchar PRIMARY KEY NOT NULL, "challenge" varchar NOT NULL, "securityKeyName" varchar, "userId" varchar, "expiresAt" bigint NOT NULL ); ALTER TABLE "limits" DISABLE ROW LEVEL SECURITY; DROP TABLE "limits" CASCADE; ALTER TABLE "sites" ALTER COLUMN "subnet" DROP NOT NULL; ALTER TABLE "sites" ALTER COLUMN "bytesIn" SET DEFAULT 0; ALTER TABLE "sites" ALTER COLUMN "bytesOut" SET DEFAULT 0; ALTER TABLE "domains" ADD COLUMN "type" varchar; ALTER TABLE "domains" ADD COLUMN "verified" boolean DEFAULT false NOT NULL; ALTER TABLE "domains" ADD COLUMN "failed" boolean DEFAULT false NOT NULL; ALTER TABLE "domains" ADD COLUMN "tries" integer DEFAULT 0 NOT NULL; ALTER TABLE "exitNodes" ADD COLUMN "maxConnections" integer; ALTER TABLE "newt" ADD COLUMN "version" varchar; ALTER TABLE "orgs" ADD COLUMN "subnet" varchar; ALTER TABLE "sites" ADD COLUMN "address" varchar; ALTER TABLE "sites" ADD COLUMN "endpoint" varchar; ALTER TABLE "sites" ADD COLUMN "publicKey" varchar; ALTER TABLE "sites" ADD COLUMN "lastHolePunch" bigint; ALTER TABLE "sites" ADD COLUMN "listenPort" integer; ALTER TABLE "user" ADD COLUMN "twoFactorSetupRequested" boolean DEFAULT false; ALTER TABLE "clientSites" ADD CONSTRAINT "clientSites_clientId_clients_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "clientSites" ADD CONSTRAINT "clientSites_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action; ALTER TABLE "clients" ADD CONSTRAINT "clients_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action; ALTER TABLE "clients" ADD CONSTRAINT "clients_exitNode_exitNodes_exitNodeId_fk" FOREIGN KEY ("exitNode") REFERENCES "public"."exitNodes"("exitNodeId") ON DELETE set null ON UPDATE no action; ALTER TABLE "clientSession" ADD CONSTRAINT "clientSession_olmId_olms_id_fk" FOREIGN KEY ("olmId") REFERENCES "public"."olms"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "olms" ADD CONSTRAINT "olms_clientId_clients_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "roleClients" ADD CONSTRAINT "roleClients_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action; ALTER TABLE "roleClients" ADD CONSTRAINT "roleClients_clientId_clients_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "webauthnCredentials" ADD CONSTRAINT "webauthnCredentials_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "userClients" ADD CONSTRAINT "userClients_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "userClients" ADD CONSTRAINT "userClients_clientId_clients_id_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "webauthnChallenge" ADD CONSTRAINT "webauthnChallenge_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; ALTER TABLE "resources" DROP COLUMN "isBaseDomain"; COMMIT; `); console.log(`Migrated database schema`); } catch (e) { console.log("Unable to migrate database schema"); console.log(e); throw e; } try { await db.execute(sql`BEGIN`); // Update all existing orgs to have the default subnet await db.execute(sql`UPDATE "orgs" SET "subnet" = '100.90.128.0/24'`); // Get all orgs and their sites to assign sequential IP addresses const orgsQuery = await db.execute(sql`SELECT "orgId" FROM "orgs"`); const orgs = orgsQuery.rows as { orgId: string }[]; for (const org of orgs) { const sitesQuery = await db.execute(sql` SELECT "siteId" FROM "sites" WHERE "orgId" = ${org.orgId} ORDER BY "siteId" `); const sites = sitesQuery.rows as { siteId: number }[]; let ipIndex = 1; for (const site of sites) { const address = `100.90.128.${ipIndex}/24`; await db.execute(sql` UPDATE "sites" SET "address" = ${address} WHERE "siteId" = ${site.siteId} `); ipIndex++; } } await db.execute(sql`COMMIT`); console.log(`Updated org subnets and site addresses`); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Unable to update org subnets"); console.log(e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.8.0.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; const version = "1.8.0"; export default async function migration() { console.log(`Running setup script ${version}...`); try { await db.execute(sql` BEGIN; ALTER TABLE "clients" ALTER COLUMN "bytesIn" SET DATA TYPE real; ALTER TABLE "clients" ALTER COLUMN "bytesOut" SET DATA TYPE real; ALTER TABLE "clientSession" ALTER COLUMN "expiresAt" SET DATA TYPE bigint; ALTER TABLE "resources" ADD COLUMN "enableProxy" boolean DEFAULT true; ALTER TABLE "sites" ADD COLUMN "remoteSubnets" text; ALTER TABLE "user" ADD COLUMN "termsAcceptedTimestamp" varchar; ALTER TABLE "user" ADD COLUMN "termsVersion" varchar; COMMIT; `); console.log(`Migrated database schema`); } catch (e) { console.log("Unable to migrate database schema"); console.log(e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsPg/1.9.0.ts ================================================ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; const version = "1.9.0"; export default async function migration() { console.log(`Running setup script ${version}...`); const resourceSiteMap = new Map(); let firstSiteId: number = 1; try { // Get the first siteId to use as default const firstSite = await db.execute( sql`SELECT "siteId" FROM "sites" LIMIT 1` ); if (firstSite.rows.length > 0) { firstSiteId = firstSite.rows[0].siteId as number; } const resources = await db.execute(sql` SELECT "resourceId", "siteId" FROM "resources" WHERE "siteId" IS NOT NULL `); for (const resource of resources.rows) { resourceSiteMap.set( resource.resourceId as number, resource.siteId as number ); } } catch (e) { console.log("Error getting resources:", e); } try { await db.execute(sql`BEGIN`); await db.execute(sql`CREATE TABLE "setupTokens" ( "tokenId" varchar PRIMARY KEY NOT NULL, "token" varchar NOT NULL, "used" boolean DEFAULT false NOT NULL, "dateCreated" varchar NOT NULL, "dateUsed" varchar );`); await db.execute(sql`CREATE TABLE "siteResources" ( "siteResourceId" serial PRIMARY KEY NOT NULL, "siteId" integer NOT NULL, "orgId" varchar NOT NULL, "name" varchar NOT NULL, "protocol" varchar NOT NULL, "proxyPort" integer NOT NULL, "destinationPort" integer NOT NULL, "destinationIp" varchar NOT NULL, "enabled" boolean DEFAULT true NOT NULL );`); await db.execute( sql`ALTER TABLE "resources" DROP CONSTRAINT "resources_siteId_sites_siteId_fk";` ); await db.execute( sql`ALTER TABLE "clients" ALTER COLUMN "lastPing" TYPE integer USING NULL;` ); await db.execute( sql`ALTER TABLE "clientSites" ADD COLUMN "endpoint" varchar;` ); await db.execute( sql`ALTER TABLE "exitNodes" ADD COLUMN "online" boolean DEFAULT false NOT NULL;` ); await db.execute( sql`ALTER TABLE "exitNodes" ADD COLUMN "lastPing" integer;` ); await db.execute( sql`ALTER TABLE "exitNodes" ADD COLUMN "type" text DEFAULT 'gerbil';` ); await db.execute(sql`ALTER TABLE "olms" ADD COLUMN "version" text;`); await db.execute(sql`ALTER TABLE "orgs" ADD COLUMN "createdAt" text;`); await db.execute( sql`ALTER TABLE "resources" ADD COLUMN "skipToIdpId" integer;` ); await db.execute( sql.raw( `ALTER TABLE "targets" ADD COLUMN "siteId" integer NOT NULL DEFAULT ${firstSiteId || 1};` ) ); await db.execute( sql`ALTER TABLE "siteResources" ADD CONSTRAINT "siteResources_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "siteResources" ADD CONSTRAINT "siteResources_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "resources" ADD CONSTRAINT "resources_skipToIdpId_idp_idpId_fk" FOREIGN KEY ("skipToIdpId") REFERENCES "public"."idp"("idpId") ON DELETE cascade ON UPDATE no action;` ); await db.execute( sql`ALTER TABLE "targets" ADD CONSTRAINT "targets_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action;` ); await db.execute(sql`ALTER TABLE "clients" DROP COLUMN "endpoint";`); await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "siteId";`); // for each resource, get all of its targets, and update the siteId to be the previously stored siteId for (const [resourceId, siteId] of resourceSiteMap) { const targets = await db.execute(sql` SELECT "targetId" FROM "targets" WHERE "resourceId" = ${resourceId} `); for (const target of targets.rows) { await db.execute(sql` UPDATE "targets" SET "siteId" = ${siteId} WHERE "targetId" = ${target.targetId} `); } } // list resources that have enableProxy false // move them to the siteResources table // remove them from the resources table const proxyFalseResources = await db.execute(sql` SELECT * FROM "resources" WHERE "enableProxy" = false `); for (const resource of proxyFalseResources.rows) { // Get the first target to derive destination IP and port const firstTarget = await db.execute(sql` SELECT "ip", "port" FROM "targets" WHERE "resourceId" = ${resource.resourceId} LIMIT 1 `); if (firstTarget.rows.length === 0) { continue; } const target = firstTarget.rows[0]; // Insert into siteResources table await db.execute(sql` INSERT INTO "siteResources" ("siteId", "orgId", "name", "protocol", "proxyPort", "destinationPort", "destinationIp", "enabled") VALUES (${resourceSiteMap.get(resource.resourceId as number)}, ${resource.orgId}, ${resource.name}, ${resource.protocol}, ${resource.proxyPort}, ${target.port}, ${target.ip}, ${resource.enabled}) `); // Delete from resources table await db.execute(sql` DELETE FROM "resources" WHERE "resourceId" = ${resource.resourceId} `); // Delete the targets for this resource await db.execute(sql` DELETE FROM "targets" WHERE "resourceId" = ${resource.resourceId} `); } await db.execute(sql`COMMIT`); console.log(`Migrated database`); } catch (e) { await db.execute(sql`ROLLBACK`); console.log("Failed to migrate db:", e); throw e; } } ================================================ FILE: server/setup/scriptsSqlite/1.0.0-beta1.ts ================================================ export default async function migration() { console.log("Running setup script 1.0.0-beta.1..."); // SQL operations would go here in ts format console.log("Done."); } ================================================ FILE: server/setup/scriptsSqlite/1.0.0-beta10.ts ================================================ import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import fs from "fs"; import yaml from "js-yaml"; export default async function migration() { console.log("Running setup script 1.0.0-beta.10..."); try { // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; delete rawConfig.server.secure_cookies; // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); console.log(`Removed deprecated config option: secure_cookies.`); } catch (e) { console.log( `Was unable to remove deprecated config option: secure_cookies. Error: ${e}` ); return; } console.log("Done."); } ================================================ FILE: server/setup/scriptsSqlite/1.0.0-beta12.ts ================================================ import { db } from "../../db/sqlite"; import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import { sql } from "drizzle-orm"; import fs from "fs"; import yaml from "js-yaml"; export default async function migration() { console.log("Running setup script 1.0.0-beta.12..."); try { // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; if (!rawConfig.flags) { rawConfig.flags = {}; } rawConfig.flags.allow_base_domain_resources = true; // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); console.log(`Added new config option: allow_base_domain_resources`); } catch (e) { console.log( `Unable to add new config option: allow_base_domain_resources. This is not critical.` ); console.error(e); } try { db.transaction((trx) => { trx.run(sql`ALTER TABLE 'resources' ADD 'isBaseDomain' integer;`); }); console.log(`Added new column: isBaseDomain`); } catch (e) { console.log("Unable to add new column: isBaseDomain"); throw e; } console.log("Done."); } ================================================ FILE: server/setup/scriptsSqlite/1.0.0-beta13.ts ================================================ import { db } from "../../db/sqlite"; import { sql } from "drizzle-orm"; const version = "1.0.0-beta.13"; export default async function migration() { console.log(`Running setup script ${version}...`); try { db.transaction((trx) => { trx.run(sql`CREATE TABLE resourceRules ( ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL, resourceId integer NOT NULL, priority integer NOT NULL, enabled integer DEFAULT true NOT NULL, action text NOT NULL, match text NOT NULL, value text NOT NULL, FOREIGN KEY (resourceId) REFERENCES resources(resourceId) ON UPDATE no action ON DELETE cascade );`); trx.run( sql`ALTER TABLE resources ADD applyRules integer DEFAULT false NOT NULL;` ); }); console.log(`Added new table and column: resourceRules, applyRules`); } catch (e) { console.log( "Unable to add new table and column: resourceRules, applyRules" ); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.0.0-beta15.ts ================================================ import { db } from "../../db/sqlite"; import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import fs from "fs"; import yaml from "js-yaml"; import { sql } from "drizzle-orm"; import { domains, orgDomains, resources } from "@server/db"; const version = "1.0.0-beta.15"; export default async function migration() { console.log(`Running setup script ${version}...`); let domain = ""; try { // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; const baseDomain = rawConfig.app.base_domain; const certResolver = rawConfig.traefik.cert_resolver; const preferWildcardCert = rawConfig.traefik.prefer_wildcard_cert; delete rawConfig.traefik.prefer_wildcard_cert; delete rawConfig.traefik.cert_resolver; delete rawConfig.app.base_domain; rawConfig.domains = { domain1: { base_domain: baseDomain } }; if (certResolver) { rawConfig.domains.domain1.cert_resolver = certResolver; } if (preferWildcardCert) { rawConfig.domains.domain1.prefer_wildcard_cert = preferWildcardCert; } // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); domain = baseDomain; console.log(`Moved base_domain to new domains section`); } catch (e) { console.log( `Unable to migrate config file and move base_domain to domains. Error: ${e}` ); throw e; } try { db.transaction((trx) => { trx.run(sql`CREATE TABLE 'domains' ( 'domainId' text PRIMARY KEY NOT NULL, 'baseDomain' text NOT NULL, 'configManaged' integer DEFAULT false NOT NULL );`); trx.run(sql`CREATE TABLE 'orgDomains' ( 'orgId' text NOT NULL, 'domainId' text NOT NULL, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade );`); trx.run( sql`ALTER TABLE 'resources' ADD 'domainId' text REFERENCES domains(domainId);` ); trx.run(sql`ALTER TABLE 'orgs' DROP COLUMN 'domain';`); }); console.log(`Migrated database schema`); } catch (e) { console.log("Unable to migrate database schema"); throw e; } try { await db.transaction(async (trx) => { await trx .insert(domains) .values({ domainId: "domain1", baseDomain: domain, configManaged: true }) .execute(); await trx.update(resources).set({ domainId: "domain1" }); const existingOrgDomains = await trx.select().from(orgDomains); for (const orgDomain of existingOrgDomains) { await trx .insert(orgDomains) .values({ orgId: orgDomain.orgId, domainId: "domain1" }) .execute(); } }); console.log(`Updated resources table with new domainId`); } catch (e) { console.log( `Unable to update resources table with new domainId. Error: ${e}` ); return; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.0.0-beta2.ts ================================================ import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import fs from "fs"; import yaml from "js-yaml"; export default async function migration() { console.log("Running setup script 1.0.0-beta.2..."); // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; // Validate the structure if (!rawConfig.app || !rawConfig.app.base_url) { throw new Error(`Invalid config file: app.base_url is missing.`); } // Move base_url to dashboard_url and calculate base_domain const baseUrl = rawConfig.app.base_url; rawConfig.app.dashboard_url = baseUrl; rawConfig.app.base_domain = getBaseDomain(baseUrl); // Remove the old base_url delete rawConfig.app.base_url; // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); console.log("Done."); } function getBaseDomain(url: string): string { const newUrl = new URL(url); const hostname = newUrl.hostname; const parts = hostname.split("."); if (parts.length <= 2) { return parts.join("."); } return parts.slice(-2).join("."); } ================================================ FILE: server/setup/scriptsSqlite/1.0.0-beta3.ts ================================================ import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import fs from "fs"; import yaml from "js-yaml"; export default async function migration() { console.log("Running setup script 1.0.0-beta.3..."); // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; // Validate the structure if (!rawConfig.gerbil) { throw new Error(`Invalid config file: gerbil is missing.`); } // Update the config rawConfig.gerbil.site_block_size = 29; // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); console.log("Done."); } ================================================ FILE: server/setup/scriptsSqlite/1.0.0-beta5.ts ================================================ import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import fs from "fs"; import yaml from "js-yaml"; import path from "path"; import { z } from "zod"; import { fromZodError } from "zod-validation-error"; export default async function migration() { console.log("Running setup script 1.0.0-beta.5..."); // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; // Validate the structure if (!rawConfig.server) { throw new Error(`Invalid config file: server is missing.`); } // Update the config rawConfig.server.resource_access_token_param = "p_token"; // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); // then try to update badger in traefik config try { const traefikPath = path.join( APP_PATH, "traefik", "traefik_config.yml" ); // read the traefik file // look for the badger middleware // set the version to v1.0.0-beta.2 /* experimental: plugins: badger: moduleName: "github.com/fosrl/badger" version: "v1.0.0-beta.2" */ const schema = z.object({ experimental: z.object({ plugins: z.object({ badger: z.object({ moduleName: z.string(), version: z.string() }) }) }) }); const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); const traefikConfig = yaml.load(traefikFileContents) as any; const parsedConfig = schema.safeParse(traefikConfig); if (!parsedConfig.success) { throw new Error(fromZodError(parsedConfig.error).toString()); } traefikConfig.experimental.plugins.badger.version = "v1.0.0-beta.2"; const updatedTraefikYaml = yaml.dump(traefikConfig); fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); console.log( "Updated the version of Badger in your Traefik configuration to v1.0.0-beta.2." ); } catch (e) { console.log( "We were unable to update the version of Badger in your Traefik configuration. Please update it manually." ); console.error(e); } console.log("Done."); } ================================================ FILE: server/setup/scriptsSqlite/1.0.0-beta6.ts ================================================ import { configFilePath1, configFilePath2 } from "@server/lib/consts"; import fs from "fs"; import yaml from "js-yaml"; export default async function migration() { console.log("Running setup script 1.0.0-beta.6..."); try { // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; // Validate the structure if (!rawConfig.server) { throw new Error(`Invalid config file: server is missing.`); } // Update the config rawConfig.server.cors = { origins: [rawConfig.app.dashboard_url], methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], headers: ["X-CSRF-Token", "Content-Type"], credentials: false }; // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); } catch (error) { console.log( "We were unable to add CORS to your config file. Please add it manually." ); console.error(error); } console.log("Done."); } ================================================ FILE: server/setup/scriptsSqlite/1.0.0-beta9.ts ================================================ import { db } from "../../db/sqlite"; import { emailVerificationCodes, passwordResetTokens, resourceOtp, resources, resourceWhitelist, targets, userInvites, users } from "../../db/sqlite"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { eq, sql } from "drizzle-orm"; import fs from "fs"; import yaml from "js-yaml"; import path from "path"; import { z } from "zod"; import { fromZodError } from "zod-validation-error"; export default async function migration() { console.log("Running setup script 1.0.0-beta.9..."); // make dir config/db/backups const appPath = APP_PATH; const dbDir = path.join(appPath, "db"); const backupsDir = path.join(dbDir, "backups"); // check if the backups directory exists and create it if it doesn't if (!fs.existsSync(backupsDir)) { fs.mkdirSync(backupsDir, { recursive: true }); } // copy the db.sqlite file to backups // add the date to the filename const date = new Date(); const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`; const dbPath = path.join(dbDir, "db.sqlite"); const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`); fs.copyFileSync(dbPath, backupPath); await db.transaction(async (trx) => { try { // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; rawConfig.server.resource_session_request_param = "p_session_request"; rawConfig.server.session_cookie_name = "p_session_token"; // rename to prevent conflicts delete rawConfig.server.resource_session_cookie_name; if (!rawConfig.flags) { rawConfig.flags = {}; } rawConfig.flags.allow_raw_resources = true; // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); } catch (e) { console.log( `Failed to add resource_session_request_param to config. Please add it manually. https://docs.pangolin.net/self-host/advanced/config-file` ); trx.rollback(); return; } try { const traefikPath = path.join( APP_PATH, "traefik", "traefik_config.yml" ); // Define schema for traefik config validation const schema = z.object({ entryPoints: z .object({ websecure: z .object({ address: z.string(), transport: z .object({ respondingTimeouts: z.object({ readTimeout: z.string() }) }) .optional() }) .optional() }) .optional(), experimental: z.object({ plugins: z.object({ badger: z.object({ moduleName: z.string(), version: z.string() }) }) }) }); const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); const traefikConfig = yaml.load(traefikFileContents) as any; const parsedConfig: any = schema.safeParse(traefikConfig); if (parsedConfig.success) { // Ensure websecure entrypoint exists if (traefikConfig.entryPoints?.websecure) { // Add transport configuration traefikConfig.entryPoints.websecure.transport = { respondingTimeouts: { readTimeout: "30m" } }; } traefikConfig.experimental.plugins.badger.version = "v1.0.0-beta.3"; const updatedTraefikYaml = yaml.dump(traefikConfig); fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); console.log("Updated Badger version in Traefik config."); } else { console.log(fromZodError(parsedConfig.error)); console.log( "We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger" ); } } catch (e) { console.log( "We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger" ); trx.rollback(); return; } try { const traefikPath = path.join( APP_PATH, "traefik", "dynamic_config.yml" ); const schema = z.object({ http: z.object({ middlewares: z.object({ "redirect-to-https": z.object({ redirectScheme: z.object({ scheme: z.string(), permanent: z.boolean() }) }) }) }) }); const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); const traefikConfig = yaml.load(traefikFileContents) as any; const parsedConfig: any = schema.safeParse(traefikConfig); if (parsedConfig.success) { // delete permanent from redirect-to-https middleware delete traefikConfig.http.middlewares["redirect-to-https"] .redirectScheme.permanent; const updatedTraefikYaml = yaml.dump(traefikConfig); fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); console.log( "Deleted permanent from redirect-to-https middleware." ); } else { console.log(fromZodError(parsedConfig.error)); console.log( "We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually." ); } } catch (e) { console.log( "We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually. Note that this is not a critical change but recommended." ); } trx.run(sql`UPDATE ${users} SET email = LOWER(email);`); trx.run( sql`UPDATE ${emailVerificationCodes} SET email = LOWER(email);` ); trx.run(sql`UPDATE ${passwordResetTokens} SET email = LOWER(email);`); trx.run(sql`UPDATE ${userInvites} SET email = LOWER(email);`); trx.run(sql`UPDATE ${resourceWhitelist} SET email = LOWER(email);`); trx.run(sql`UPDATE ${resourceOtp} SET email = LOWER(email);`); const resourcesAll = await trx .select({ resourceId: resources.resourceId, fullDomain: resources.fullDomain, subdomain: resources.subdomain }) .from(resources); trx.run(`DROP INDEX resources_fullDomain_unique;`); trx.run(`ALTER TABLE resources DROP COLUMN fullDomain; `); trx.run(`ALTER TABLE resources DROP COLUMN subdomain; `); trx.run(sql`ALTER TABLE resources ADD COLUMN fullDomain TEXT; `); trx.run(sql`ALTER TABLE resources ADD COLUMN subdomain TEXT; `); trx.run(sql`ALTER TABLE resources ADD COLUMN http INTEGER DEFAULT true NOT NULL; `); trx.run(sql`ALTER TABLE resources ADD COLUMN protocol TEXT DEFAULT 'tcp' NOT NULL; `); trx.run(sql`ALTER TABLE resources ADD COLUMN proxyPort INTEGER; `); // write the new fullDomain and subdomain values back to the database for (const resource of resourcesAll) { await trx .update(resources) .set({ fullDomain: resource.fullDomain, subdomain: resource.subdomain }) .where(eq(resources.resourceId, resource.resourceId)); } const targetsAll = await trx .select({ targetId: targets.targetId, method: targets.method }) .from(targets); trx.run(`ALTER TABLE targets DROP COLUMN method; `); trx.run(`ALTER TABLE targets DROP COLUMN protocol; `); trx.run(sql`ALTER TABLE targets ADD COLUMN method TEXT; `); // write the new method and protocol values back to the database for (const target of targetsAll) { await trx .update(targets) .set({ method: target.method }) .where(eq(targets.targetId, target.targetId)); } trx.run( sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;` ); trx.run( sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);` ); }); console.log("Done."); } ================================================ FILE: server/setup/scriptsSqlite/1.0.0.ts ================================================ import { APP_PATH } from "@server/lib/consts"; import fs from "fs"; import yaml from "js-yaml"; import path from "path"; import { z } from "zod"; import { fromZodError } from "zod-validation-error"; const version = "1.0.0"; export default async function migration() { console.log(`Running setup script ${version}...`); try { const traefikPath = path.join( APP_PATH, "traefik", "traefik_config.yml" ); const schema = z.object({ experimental: z.object({ plugins: z.object({ badger: z.object({ moduleName: z.string(), version: z.string() }) }) }) }); const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); const traefikConfig = yaml.load(traefikFileContents) as any; const parsedConfig = schema.safeParse(traefikConfig); if (!parsedConfig.success) { throw new Error(fromZodError(parsedConfig.error).toString()); } traefikConfig.experimental.plugins.badger.version = "v1.0.0"; const updatedTraefikYaml = yaml.dump(traefikConfig); fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); console.log( "Updated the version of Badger in your Traefik configuration to 1.0.0" ); } catch (e) { console.log( "We were unable to update the version of Badger in your Traefik configuration. Please update it manually." ); console.error(e); } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.1.0.ts ================================================ import { db } from "../../db/sqlite"; import { sql } from "drizzle-orm"; const version = "1.1.0"; export default async function migration() { console.log(`Running setup script ${version}...`); try { db.transaction((trx) => { trx.run(sql`CREATE TABLE 'supporterKey' ( 'keyId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'key' text NOT NULL, 'githubUsername' text NOT NULL, 'phrase' text, 'tier' text, 'valid' integer DEFAULT false NOT NULL );`); }); console.log(`Migrated database schema`); } catch (e) { console.log("Unable to migrate database schema"); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.10.0.ts ================================================ import { __DIRNAME, APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import { readFileSync } from "fs"; import path, { join } from "path"; const version = "1.10.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); try { const resources = db .prepare("SELECT resourceId FROM resources") .all() as Array<{ resourceId: number }>; const siteResources = db .prepare("SELECT siteResourceId FROM siteResources") .all() as Array<{ siteResourceId: number }>; db.transaction(() => { db.exec(` ALTER TABLE 'exitNodes' ADD 'region' text; ALTER TABLE 'idpOidcConfig' ADD 'variant' text DEFAULT 'oidc' NOT NULL; ALTER TABLE 'resources' ADD 'niceId' text DEFAULT '' NOT NULL; ALTER TABLE 'siteResources' ADD 'niceId' text DEFAULT '' NOT NULL; ALTER TABLE 'userOrgs' ADD 'autoProvisioned' integer DEFAULT false; ALTER TABLE 'targets' ADD 'pathMatchType' text; ALTER TABLE 'targets' ADD 'path' text; ALTER TABLE 'resources' ADD 'headers' text; `); // this diverges from the schema a bit because the schema does not have a default on niceId but was required for the migration and I dont think it will effect much down the line... const usedNiceIds: string[] = []; for (const resourceId of resources) { // Generate a unique name and ensure it's unique let niceId = ""; let loops = 0; while (true) { if (loops > 100) { throw new Error("Could not generate a unique name"); } niceId = generateName(); if (!usedNiceIds.includes(niceId)) { usedNiceIds.push(niceId); break; } loops++; } db.prepare( `UPDATE resources SET niceId = ? WHERE resourceId = ?` ).run(niceId, resourceId.resourceId); } for (const resourceId of siteResources) { // Generate a unique name and ensure it's unique let niceId = ""; let loops = 0; while (true) { if (loops > 100) { throw new Error("Could not generate a unique name"); } niceId = generateName(); if (!usedNiceIds.includes(niceId)) { usedNiceIds.push(niceId); break; } loops++; } db.prepare( `UPDATE siteResources SET niceId = ? WHERE siteResourceId = ?` ).run(niceId, resourceId.siteResourceId); } // Handle auto-provisioned users for identity providers const autoProvisionIdps = db .prepare("SELECT idpId FROM idp WHERE autoProvision = 1") .all() as Array<{ idpId: number }>; for (const idp of autoProvisionIdps) { // Get all users with this identity provider const usersWithIdp = db .prepare("SELECT id FROM user WHERE idpId = ?") .all(idp.idpId) as Array<{ id: string }>; // Update userOrgs to set autoProvisioned to true for these users for (const user of usersWithIdp) { db.prepare( "UPDATE userOrgs SET autoProvisioned = 1 WHERE userId = ?" ).run(user.id); } } })(); console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); throw e; } } const dev = process.env.ENVIRONMENT !== "prod"; let file; if (!dev) { file = join(__DIRNAME, "names.json"); } else { file = join("server/db/names.json"); } export const names = JSON.parse(readFileSync(file, "utf-8")); export function generateName(): string { const name = ( names.descriptors[ Math.floor(Math.random() * names.descriptors.length) ] + "-" + names.animals[Math.floor(Math.random() * names.animals.length)] ) .toLowerCase() .replace(/\s/g, "-"); // clean out any non-alphanumeric characters except for dashes return name.replace(/[^a-z0-9-]/g, ""); } ================================================ FILE: server/setup/scriptsSqlite/1.10.1.ts ================================================ import { APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; const version = "1.10.1"; 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); try { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.exec(`ALTER TABLE "targets" RENAME TO "targets_old"; --> statement-breakpoint CREATE TABLE "targets" ( "targetId" INTEGER PRIMARY KEY AUTOINCREMENT, "resourceId" INTEGER NOT NULL, "siteId" INTEGER NOT NULL, "ip" TEXT NOT NULL, "method" TEXT, "port" INTEGER NOT NULL, "internalPort" INTEGER, "enabled" INTEGER NOT NULL DEFAULT 1, "path" TEXT, "pathMatchType" TEXT, FOREIGN KEY ("resourceId") REFERENCES "resources"("resourceId") ON UPDATE no action ON DELETE cascade, FOREIGN KEY ("siteId") REFERENCES "sites"("siteId") ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint INSERT INTO "targets" ( "targetId", "resourceId", "siteId", "ip", "method", "port", "internalPort", "enabled", "path", "pathMatchType" ) SELECT targetId, resourceId, siteId, ip, method, port, internalPort, enabled, path, pathMatchType FROM "targets_old"; --> statement-breakpoint DROP TABLE "targets_old";`); })(); db.pragma("foreign_keys = ON"); console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); throw e; } } ================================================ FILE: server/setup/scriptsSqlite/1.10.2.ts ================================================ import { APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; const version = "1.10.2"; 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); const resources = db.prepare("SELECT * FROM resources").all() as Array<{ resourceId: number; headers: string | null; }>; try { db.pragma("foreign_keys = OFF"); db.transaction(() => { for (const resource of resources) { const headers = resource.headers; if (headers && headers !== "") { // lets convert it to json // fist split at commas const headersArray = headers .split(",") .map((header: string) => { const [name, ...valueParts] = header.split(":"); const value = valueParts.join(":").trim(); return { name: name.trim(), value }; }); db.prepare( ` UPDATE "resources" SET "headers" = ? WHERE "resourceId" = ?` ).run(JSON.stringify(headersArray), resource.resourceId); console.log( `Updated resource ${resource.resourceId} headers to JSON format` ); } } })(); db.pragma("foreign_keys = ON"); console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); throw e; } } ================================================ FILE: server/setup/scriptsSqlite/1.11.0.ts ================================================ import { APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; import { randomUUID } from "crypto"; const version = "1.11.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); db.transaction(() => { db.prepare( ` CREATE TABLE 'account' ( 'accountId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'userId' text NOT NULL, FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'accountDomains' ( 'accountId' integer NOT NULL, 'domainId' text NOT NULL, FOREIGN KEY ('accountId') REFERENCES 'account'('accountId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'certificates' ( 'certId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'domain' text NOT NULL, 'domainId' text, 'wildcard' integer DEFAULT false, 'status' text DEFAULT 'pending' NOT NULL, 'expiresAt' integer, 'lastRenewalAttempt' integer, 'createdAt' integer NOT NULL, 'updatedAt' integer NOT NULL, 'orderId' text, 'errorMessage' text, 'renewalCount' integer DEFAULT 0, 'certFile' text, 'keyFile' text, FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( `CREATE UNIQUE INDEX 'certificates_domain_unique' ON 'certificates' ('domain');` ).run(); db.prepare( ` CREATE TABLE 'customers' ( 'customerId' text PRIMARY KEY NOT NULL, 'orgId' text NOT NULL, 'email' text, 'name' text, 'phone' text, 'address' text, 'createdAt' integer NOT NULL, 'updatedAt' integer NOT NULL, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'dnsChallenges' ( 'dnsChallengeId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'domain' text NOT NULL, 'token' text NOT NULL, 'keyAuthorization' text NOT NULL, 'createdAt' integer NOT NULL, 'expiresAt' integer NOT NULL, 'completed' integer DEFAULT false ); ` ).run(); db.prepare( ` CREATE TABLE 'domainNamespaces' ( 'domainNamespaceId' text PRIMARY KEY NOT NULL, 'domainId' text NOT NULL, FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE set null ); ` ).run(); db.prepare( ` CREATE TABLE 'exitNodeOrgs' ( 'exitNodeId' integer NOT NULL, 'orgId' text NOT NULL, FOREIGN KEY ('exitNodeId') REFERENCES 'exitNodes'('exitNodeId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'loginPage' ( 'loginPageId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'subdomain' text, 'fullDomain' text, 'exitNodeId' integer, 'domainId' text, FOREIGN KEY ('exitNodeId') REFERENCES 'exitNodes'('exitNodeId') ON UPDATE no action ON DELETE set null, FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE set null ); ` ).run(); db.prepare( ` CREATE TABLE 'loginPageOrg' ( 'loginPageId' integer NOT NULL, 'orgId' text NOT NULL, FOREIGN KEY ('loginPageId') REFERENCES 'loginPage'('loginPageId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'remoteExitNodeSession' ( 'id' text PRIMARY KEY NOT NULL, 'remoteExitNodeId' text NOT NULL, 'expiresAt' integer NOT NULL, FOREIGN KEY ('remoteExitNodeId') REFERENCES 'remoteExitNode'('id') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'remoteExitNode' ( 'id' text PRIMARY KEY NOT NULL, 'secretHash' text NOT NULL, 'dateCreated' text NOT NULL, 'version' text, 'exitNodeId' integer, FOREIGN KEY ('exitNodeId') REFERENCES 'exitNodes'('exitNodeId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'sessionTransferToken' ( 'token' text PRIMARY KEY NOT NULL, 'sessionId' text NOT NULL, 'encryptedSession' text NOT NULL, 'expiresAt' integer NOT NULL, FOREIGN KEY ('sessionId') REFERENCES 'session'('id') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'subscriptionItems' ( 'subscriptionItemId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'subscriptionId' text NOT NULL, 'planId' text NOT NULL, 'priceId' text, 'meterId' text, 'unitAmount' real, 'tiers' text, 'interval' text, 'currentPeriodStart' integer, 'currentPeriodEnd' integer, 'name' text, FOREIGN KEY ('subscriptionId') REFERENCES 'subscriptions'('subscriptionId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'subscriptions' ( 'subscriptionId' text PRIMARY KEY NOT NULL, 'customerId' text NOT NULL, 'status' text DEFAULT 'active' NOT NULL, 'canceledAt' integer, 'createdAt' integer NOT NULL, 'updatedAt' integer, 'billingCycleAnchor' integer, FOREIGN KEY ('customerId') REFERENCES 'customers'('customerId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'usage' ( 'usageId' text PRIMARY KEY NOT NULL, 'featureId' text NOT NULL, 'orgId' text NOT NULL, 'meterId' text, 'instantaneousValue' real, 'latestValue' real NOT NULL, 'previousValue' real, 'updatedAt' integer NOT NULL, 'rolledOverAt' integer, 'nextRolloverAt' integer, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'usageNotifications' ( 'notificationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'orgId' text NOT NULL, 'featureId' text NOT NULL, 'limitId' text NOT NULL, 'notificationType' text NOT NULL, 'sentAt' integer NOT NULL, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'resourceHeaderAuth' ( 'headerAuthId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'resourceId' integer NOT NULL, 'headerAuthHash' text NOT NULL, FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'targetHealthCheck' ( 'targetHealthCheckId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'targetId' integer NOT NULL, 'hcEnabled' integer DEFAULT false NOT NULL, 'hcPath' text, 'hcScheme' text, 'hcMode' text DEFAULT 'http', 'hcHostname' text, 'hcPort' integer, 'hcInterval' integer DEFAULT 30, 'hcUnhealthyInterval' integer DEFAULT 30, 'hcTimeout' integer DEFAULT 5, 'hcHeaders' text, 'hcFollowRedirects' integer DEFAULT true, 'hcMethod' text DEFAULT 'GET', 'hcStatus' integer, 'hcHealth' text DEFAULT 'unknown', FOREIGN KEY ('targetId') REFERENCES 'targets'('targetId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare(`DROP TABLE 'limits';`).run(); db.prepare( ` CREATE TABLE 'limits' ( 'limitId' text PRIMARY KEY NOT NULL, 'featureId' text NOT NULL, 'orgId' text NOT NULL, 'value' real, 'description' text, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare(`ALTER TABLE 'orgs' ADD 'settings' text;`).run(); db.prepare(`ALTER TABLE 'targets' ADD 'rewritePath' text;`).run(); db.prepare(`ALTER TABLE 'targets' ADD 'rewritePathType' text;`).run(); db.prepare( `ALTER TABLE 'targets' ADD 'priority' integer DEFAULT 100 NOT NULL;` ).run(); const webauthnCredentials = db .prepare( `SELECT credentialId, publicKey, userId, signCount, transports, name, lastUsed, dateCreated FROM 'webauthnCredentials'` ) .all() as { credentialId: string; publicKey: string; userId: string; signCount: number; transports: string | null; name: string | null; lastUsed: string; dateCreated: string; }[]; db.prepare(`DELETE FROM 'webauthnCredentials';`).run(); for (const webauthnCredential of webauthnCredentials) { const newCredentialId = isoBase64URL.fromBuffer( new Uint8Array( Buffer.from(webauthnCredential.credentialId, "base64") ) ); const newPublicKey = isoBase64URL.fromBuffer( new Uint8Array( Buffer.from(webauthnCredential.publicKey, "base64") ) ); // Insert the updated record with converted values db.prepare( `INSERT INTO 'webauthnCredentials' (credentialId, publicKey, userId, signCount, transports, name, lastUsed, dateCreated) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ).run( newCredentialId, newPublicKey, webauthnCredential.userId, webauthnCredential.signCount, webauthnCredential.transports, webauthnCredential.name, webauthnCredential.lastUsed, webauthnCredential.dateCreated ); } // 1. Add the column (nullable or with placeholder) if it doesn’t exist yet db.prepare( `ALTER TABLE resources ADD COLUMN resourceGuid TEXT DEFAULT 'PLACEHOLDER';` ).run(); // 2. Select all rows const resources = db .prepare(`SELECT resourceId FROM resources`) .all() as { resourceId: number; }[]; // 3. Prefill with random UUIDs const updateStmt = db.prepare( `UPDATE resources SET resourceGuid = ? WHERE resourceId = ?` ); for (const row of resources) { updateStmt.run(randomUUID(), row.resourceId); } // get all of the targets const targets = db.prepare(`SELECT targetId FROM targets`).all() as { targetId: number; }[]; const insertTargetHealthCheckStmt = db.prepare( `INSERT INTO targetHealthCheck (targetId) VALUES (?)` ); for (const target of targets) { insertTargetHealthCheckStmt.run(target.targetId); } db.prepare( `CREATE UNIQUE INDEX resources_resourceGuid_unique ON resources ('resourceGuid');` ).run(); })(); console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.11.1.ts ================================================ import { APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; const version = "1.11.1"; 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); db.transaction(() => { db.prepare(`UPDATE exitNodes SET online = 1`).run(); // mark exit nodes as online })(); console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.12.0.ts ================================================ import { APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; const version = "1.12.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); try { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.prepare( `UPDATE 'resourceRules' SET match = 'COUNTRY' WHERE match = 'GEOIP'` ).run(); db.prepare( ` CREATE TABLE 'accessAuditLog' ( 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'timestamp' integer NOT NULL, 'orgId' text NOT NULL, 'actorType' text, 'actor' text, 'actorId' text, 'resourceId' integer, 'ip' text, 'location' text, 'type' text NOT NULL, 'action' integer NOT NULL, 'userAgent' text, 'metadata' text, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( `CREATE INDEX 'idx_identityAuditLog_timestamp' ON 'accessAuditLog' ('timestamp');` ).run(); db.prepare( `CREATE INDEX 'idx_identityAuditLog_org_timestamp' ON 'accessAuditLog' ('orgId','timestamp');` ).run(); db.prepare( ` CREATE TABLE 'actionAuditLog' ( 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'timestamp' integer NOT NULL, 'orgId' text NOT NULL, 'actorType' text NOT NULL, 'actor' text NOT NULL, 'actorId' text NOT NULL, 'action' text NOT NULL, 'metadata' text, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( `CREATE INDEX 'idx_actionAuditLog_timestamp' ON 'actionAuditLog' ('timestamp');` ).run(); db.prepare( `CREATE INDEX 'idx_actionAuditLog_org_timestamp' ON 'actionAuditLog' ('orgId','timestamp');` ).run(); db.prepare( ` CREATE TABLE 'dnsRecords' ( 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'domainId' text NOT NULL, 'recordType' text NOT NULL, 'baseDomain' text, 'value' text NOT NULL, 'verified' integer DEFAULT false NOT NULL, FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'requestAuditLog' ( 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'timestamp' integer NOT NULL, 'orgId' text, 'action' integer NOT NULL, 'reason' integer NOT NULL, 'actorType' text, 'actor' text, 'actorId' text, 'resourceId' integer, 'ip' text, 'location' text, 'userAgent' text, 'metadata' text, 'headers' text, 'query' text, 'originalRequestURL' text, 'scheme' text, 'host' text, 'path' text, 'method' text, 'tls' integer, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'blueprints' ( 'blueprintId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'orgId' text NOT NULL, 'name' text NOT NULL, 'source' text NOT NULL, 'createdAt' integer NOT NULL, 'succeeded' integer NOT NULL, 'contents' text NOT NULL, 'message' text, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( `CREATE INDEX 'idx_requestAuditLog_timestamp' ON 'requestAuditLog' ('timestamp');` ).run(); db.prepare( `CREATE INDEX 'idx_requestAuditLog_org_timestamp' ON 'requestAuditLog' ('orgId','timestamp');` ).run(); db.prepare( ` CREATE TABLE '__new_resources' ( 'resourceId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'resourceGuid' text(36) NOT NULL, 'orgId' text NOT NULL, 'niceId' text NOT NULL, 'name' text NOT NULL, 'subdomain' text, 'fullDomain' text, 'domainId' text, 'ssl' integer DEFAULT false NOT NULL, 'blockAccess' integer DEFAULT false NOT NULL, 'sso' integer DEFAULT true NOT NULL, 'http' integer DEFAULT true NOT NULL, 'protocol' text NOT NULL, 'proxyPort' integer, 'emailWhitelistEnabled' integer DEFAULT false NOT NULL, 'applyRules' integer DEFAULT false NOT NULL, 'enabled' integer DEFAULT true NOT NULL, 'stickySession' integer DEFAULT false NOT NULL, 'tlsServerName' text, 'setHostHeader' text, 'enableProxy' integer DEFAULT true, 'skipToIdpId' integer, 'headers' text, 'proxyProtocol' integer DEFAULT false NOT NULL, 'proxyProtocolVersion' integer DEFAULT 1, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE set null, FOREIGN KEY ('skipToIdpId') REFERENCES 'idp'('idpId') ON UPDATE no action ON DELETE set null ); ` ).run(); db.prepare( `INSERT INTO '__new_resources'("resourceId", "resourceGuid", "orgId", "niceId", "name", "subdomain", "fullDomain", "domainId", "ssl", "blockAccess", "sso", "http", "protocol", "proxyPort", "emailWhitelistEnabled", "applyRules", "enabled", "stickySession", "tlsServerName", "setHostHeader", "enableProxy", "skipToIdpId", "headers") SELECT "resourceId", "resourceGuid", "orgId", "niceId", "name", "subdomain", "fullDomain", "domainId", "ssl", "blockAccess", "sso", "http", "protocol", "proxyPort", "emailWhitelistEnabled", "applyRules", "enabled", "stickySession", "tlsServerName", "setHostHeader", "enableProxy", "skipToIdpId", "headers" FROM 'resources';` ).run(); db.prepare(`DROP TABLE 'resources';`).run(); db.prepare( `ALTER TABLE '__new_resources' RENAME TO 'resources';` ).run(); db.prepare( `CREATE UNIQUE INDEX 'resources_resourceGuid_unique' ON 'resources' ('resourceGuid');` ).run(); db.prepare(`ALTER TABLE 'domains' ADD 'certResolver' text;`).run(); db.prepare( `ALTER TABLE 'domains' ADD 'preferWildcardCert' integer;` ).run(); db.prepare( `ALTER TABLE 'orgs' ADD 'requireTwoFactor' integer;` ).run(); db.prepare( `ALTER TABLE 'orgs' ADD 'maxSessionLengthHours' integer;` ).run(); db.prepare( `ALTER TABLE 'orgs' ADD 'passwordExpiryDays' integer;` ).run(); db.prepare( `ALTER TABLE 'orgs' ADD 'settingsLogRetentionDaysRequest' integer DEFAULT 7 NOT NULL;` ).run(); db.prepare( `ALTER TABLE 'orgs' ADD 'settingsLogRetentionDaysAccess' integer DEFAULT 0 NOT NULL;` ).run(); db.prepare( `ALTER TABLE 'orgs' ADD 'settingsLogRetentionDaysAction' integer DEFAULT 0 NOT NULL;` ).run(); db.prepare(`ALTER TABLE 'orgs' DROP COLUMN 'settings';`).run(); db.prepare( `ALTER TABLE 'resourceSessions' ADD 'issuedAt' integer;` ).run(); db.prepare(`ALTER TABLE 'session' ADD 'issuedAt' integer;`).run(); db.prepare( `ALTER TABLE 'user' ADD 'lastPasswordChange' integer;` ).run(); db.prepare( `ALTER TABLE 'remoteExitNode' ADD 'secondaryVersion' text;` ).run(); // get all of the domains const domains = db .prepare(`SELECT domainId, baseDomain from domains`) .all() as { domainId: number; baseDomain: string; }[]; for (const domain of domains) { // insert two records into the dnsRecords table for each domain const insert = db.prepare( `INSERT INTO 'dnsRecords' (domainId, recordType, baseDomain, value, verified) VALUES (?, 'A', ?, ?, 1)` ); insert.run( domain.domainId, `*.${domain.baseDomain}`, `Server IP Address` ); insert.run( domain.domainId, `${domain.baseDomain}`, `Server IP Address` ); } })(); db.pragma("foreign_keys = ON"); console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.13.0.ts ================================================ import { __DIRNAME, APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import { readFileSync } from "fs"; import path, { join } from "path"; const version = "1.13.0"; const dev = process.env.ENVIRONMENT !== "prod"; let file; if (!dev) { file = join(__DIRNAME, "names.json"); } else { file = join("server/db/names.json"); } export const names = JSON.parse(readFileSync(file, "utf-8")); export function generateName(): string { const name = ( names.descriptors[ Math.floor(Math.random() * names.descriptors.length) ] + "-" + names.animals[Math.floor(Math.random() * names.animals.length)] ) .toLowerCase() .replace(/\s/g, "-"); // clean out any non-alphanumeric characters except for dashes return name.replace(/[^a-z0-9-]/g, ""); } 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); try { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.prepare( `ALTER TABLE 'clientSites' RENAME TO 'clientSitesAssociationsCache';` ).run(); db.prepare( `ALTER TABLE 'clients' RENAME COLUMN 'id' TO 'clientId';` ).run(); db.prepare( ` CREATE TABLE 'clientSiteResources' ( 'clientId' integer NOT NULL, 'siteResourceId' integer NOT NULL, FOREIGN KEY ('clientId') REFERENCES 'clients'('clientId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('siteResourceId') REFERENCES 'siteResources'('siteResourceId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'clientSiteResourcesAssociationsCache' ( 'clientId' integer NOT NULL, 'siteResourceId' integer NOT NULL ); ` ).run(); db.prepare( ` CREATE TABLE 'deviceWebAuthCodes' ( 'codeId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'code' text NOT NULL, 'ip' text, 'city' text, 'deviceName' text, 'applicationName' text NOT NULL, 'expiresAt' integer NOT NULL, 'createdAt' integer NOT NULL, 'verified' integer DEFAULT false NOT NULL, 'userId' text, FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( `CREATE UNIQUE INDEX 'deviceWebAuthCodes_code_unique' ON 'deviceWebAuthCodes' ('code');` ).run(); db.prepare( ` CREATE TABLE 'roleSiteResources' ( 'roleId' integer NOT NULL, 'siteResourceId' integer NOT NULL, FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('siteResourceId') REFERENCES 'siteResources'('siteResourceId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'userSiteResources' ( 'userId' text NOT NULL, 'siteResourceId' integer NOT NULL, FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('siteResourceId') REFERENCES 'siteResources'('siteResourceId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE '__new_clientSitesAssociationsCache' ( 'clientId' integer NOT NULL, 'siteId' integer NOT NULL, 'isRelayed' integer DEFAULT false NOT NULL, 'endpoint' text, 'publicKey' text ); ` ).run(); db.prepare( `INSERT INTO '__new_clientSitesAssociationsCache'("clientId", "siteId", "isRelayed", "endpoint", "publicKey") SELECT "clientId", "siteId", "isRelayed", "endpoint", NULL FROM 'clientSitesAssociationsCache';` ).run(); db.prepare(`DROP TABLE 'clientSitesAssociationsCache';`).run(); db.prepare( `ALTER TABLE '__new_clientSitesAssociationsCache' RENAME TO 'clientSitesAssociationsCache';` ).run(); db.prepare( `ALTER TABLE 'clients' ADD 'userId' text REFERENCES 'user'('id');` ).run(); db.prepare( `ALTER TABLE 'clients' ADD COLUMN 'niceId' TEXT NOT NULL DEFAULT 'PLACEHOLDER';` ).run(); db.prepare(`ALTER TABLE 'clients' ADD 'olmId' text;`).run(); db.prepare( ` CREATE TABLE '__new_siteResources' ( 'siteResourceId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'siteId' integer NOT NULL, 'orgId' text NOT NULL, 'niceId' text NOT NULL, 'name' text NOT NULL, 'mode' text NOT NULL, 'protocol' text, 'proxyPort' integer, 'destinationPort' integer, 'destination' text NOT NULL, 'enabled' integer DEFAULT true NOT NULL, 'alias' text, 'aliasAddress' text, FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( `INSERT INTO '__new_siteResources'("siteResourceId", "siteId", "orgId", "niceId", "name", "mode", "protocol", "proxyPort", "destinationPort", "destination", "enabled", "alias", "aliasAddress") SELECT "siteResourceId", "siteId", "orgId", "niceId", "name", 'host', "protocol", "proxyPort", "destinationPort", "destinationIp", "enabled", NULL, NULL FROM 'siteResources';` ).run(); db.prepare(`DROP TABLE 'siteResources';`).run(); db.prepare( `ALTER TABLE '__new_siteResources' RENAME TO 'siteResources';` ).run(); db.prepare( ` CREATE TABLE '__new_olms' ( 'id' text PRIMARY KEY NOT NULL, 'secretHash' text NOT NULL, 'dateCreated' text NOT NULL, 'version' text, 'agent' text, 'name' text, 'clientId' integer, 'userId' text, FOREIGN KEY ('clientId') REFERENCES 'clients'('clientId') ON UPDATE no action ON DELETE set null, FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( `INSERT INTO '__new_olms'("id", "secretHash", "dateCreated", "version", "agent", "name", "clientId", "userId") SELECT "id", "secretHash", "dateCreated", "version", NULL, NULL, "clientId", NULL FROM 'olms';` ).run(); db.prepare(`DROP TABLE 'olms';`).run(); db.prepare(`ALTER TABLE '__new_olms' RENAME TO 'olms';`).run(); db.prepare( ` CREATE TABLE '__new_roleClients' ( 'roleId' integer NOT NULL, 'clientId' integer NOT NULL, FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('clientId') REFERENCES 'clients'('clientId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( `INSERT INTO '__new_roleClients'("roleId", "clientId") SELECT "roleId", "clientId" FROM 'roleClients';` ).run(); db.prepare(`DROP TABLE 'roleClients';`).run(); db.prepare( `ALTER TABLE '__new_roleClients' RENAME TO 'roleClients';` ).run(); db.prepare( ` CREATE TABLE '__new_userClients' ( 'userId' text NOT NULL, 'clientId' integer NOT NULL, FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('clientId') REFERENCES 'clients'('clientId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( `INSERT INTO '__new_userClients'("userId", "clientId") SELECT "userId", "clientId" FROM 'userClients';` ).run(); db.prepare(`DROP TABLE 'userClients';`).run(); db.prepare( `ALTER TABLE '__new_userClients' RENAME TO 'userClients';` ).run(); db.prepare(`ALTER TABLE 'orgs' ADD 'utilitySubnet' text;`).run(); db.prepare( `ALTER TABLE 'session' ADD 'deviceAuthUsed' integer DEFAULT false NOT NULL;` ).run(); db.prepare( `ALTER TABLE 'targetHealthCheck' ADD 'hcTlsServerName' text;` ).run(); // set 100.96.128.0/24 as the utility subnet on all of the orgs db.prepare( `UPDATE 'orgs' SET 'utilitySubnet' = '100.96.128.0/24'` ).run(); // Query all of the sites to get their remoteSubnets before dropping the column const sitesRemoteSubnets = db .prepare( `SELECT siteId, remoteSubnets FROM 'sites' WHERE remoteSubnets IS NOT NULL` ) .all() as { siteId: number; remoteSubnets: string | null; }[]; db.prepare( `ALTER TABLE 'sites' DROP COLUMN 'remoteSubnets';` ).run(); // get all of the siteResources and set the aliasAddress to 100.96.128.x starting at .8 const siteResourcesForAlias = db .prepare( `SELECT siteResourceId FROM 'siteResources' ORDER BY siteResourceId ASC` ) .all() as { siteResourceId: number; }[]; const updateAliasAddress = db.prepare( `UPDATE 'siteResources' SET aliasAddress = ? WHERE siteResourceId = ?` ); let aliasIpOctet = 8; for (const siteResource of siteResourcesForAlias) { const aliasAddress = `100.96.128.${aliasIpOctet}`; updateAliasAddress.run( aliasAddress, siteResource.siteResourceId ); aliasIpOctet++; } // For each site with remote subnets we need to create a site resource of type cidr for each remote subnet const insertCidrResource = db.prepare( `INSERT INTO 'siteResources' ('siteId', 'destination', 'mode', 'name', 'orgId', 'niceId') SELECT ?, ?, 'cidr', 'Remote Subnet', orgId, ? FROM 'sites' WHERE siteId = ?` ); for (const site of sitesRemoteSubnets) { if (site.remoteSubnets) { const subnets = site.remoteSubnets.split(","); for (const subnet of subnets) { // Generate a unique niceId for each new site resource const niceId = generateName(); insertCidrResource.run( site.siteId, subnet.trim(), niceId, site.siteId ); } } } // Associate clients with site resources based on their previous site access // Get all client-site associations from the renamed clientSitesAssociationsCache table const clientSiteAssociations = db .prepare( `SELECT clientId, siteId FROM 'clientSitesAssociationsCache'` ) .all() as { clientId: number; siteId: number; }[]; const getSiteResources = db.prepare( `SELECT siteResourceId FROM 'siteResources' WHERE siteId = ?` ); const insertClientSiteResource = db.prepare( `INSERT INTO 'clientSiteResources' ('clientId', 'siteResourceId') VALUES (?, ?)` ); // create a clientSiteResourcesAssociationsCache entry for each existing association as well const insertClientSiteResourceCache = db.prepare( `INSERT INTO 'clientSiteResourcesAssociationsCache' ('clientId', 'siteResourceId') VALUES (?, ?)` ); // For each client-site association, find all site resources for that site for (const association of clientSiteAssociations) { const siteResources = getSiteResources.all( association.siteId ) as { siteResourceId: number; }[]; // Associate the client with all site resources from this site for (const siteResource of siteResources) { insertClientSiteResource.run( association.clientId, siteResource.siteResourceId ); insertClientSiteResourceCache.run( association.clientId, siteResource.siteResourceId ); } } // Associate existing site resources with their org's admin role const siteResourcesWithOrg = db .prepare(`SELECT siteResourceId, orgId FROM 'siteResources'`) .all() as { siteResourceId: number; orgId: string; }[]; const getAdminRole = db.prepare( `SELECT roleId FROM 'roles' WHERE orgId = ? AND isAdmin = 1 LIMIT 1` ); const checkExistingAssociation = db.prepare( `SELECT 1 FROM 'roleSiteResources' WHERE roleId = ? AND siteResourceId = ? LIMIT 1` ); const insertRoleSiteResource = db.prepare( `INSERT INTO 'roleSiteResources' ('roleId', 'siteResourceId') VALUES (?, ?)` ); for (const siteResource of siteResourcesWithOrg) { const adminRole = getAdminRole.get(siteResource.orgId) as | { roleId: number } | undefined; if (adminRole) { const existing = checkExistingAssociation.get( adminRole.roleId, siteResource.siteResourceId ); if (!existing) { insertRoleSiteResource.run( adminRole.roleId, siteResource.siteResourceId ); } } } // Populate niceId for clients const clients = db .prepare(`SELECT clientId FROM 'clients'`) .all() as { clientId: number; }[]; const usedNiceIds: string[] = []; for (const clientId of clients) { // Generate a unique name and ensure it's unique let niceId = ""; let loops = 0; while (true) { if (loops > 100) { throw new Error("Could not generate a unique name"); } niceId = generateName(); if (!usedNiceIds.includes(niceId)) { usedNiceIds.push(niceId); break; } loops++; } db.prepare( `UPDATE clients SET niceId = ? WHERE clientId = ?` ).run(niceId, clientId.clientId); } })(); db.pragma("foreign_keys = ON"); console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.14.0.ts ================================================ import { __DIRNAME, APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; const version = "1.14.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); try { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.prepare( ` CREATE TABLE 'loginPageBranding' ( 'loginPageBrandingId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'logoUrl' text NOT NULL, 'logoWidth' integer NOT NULL, 'logoHeight' integer NOT NULL, 'primaryColor' text, 'resourceTitle' text NOT NULL, 'resourceSubtitle' text, 'orgTitle' text, 'orgSubtitle' text ); ` ).run(); db.prepare( ` CREATE TABLE 'loginPageBrandingOrg' ( 'loginPageBrandingId' integer NOT NULL, 'orgId' text NOT NULL, FOREIGN KEY ('loginPageBrandingId') REFERENCES 'loginPageBranding'('loginPageBrandingId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'resourceHeaderAuthExtendedCompatibility' ( 'headerAuthExtendedCompatibilityId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'resourceId' integer NOT NULL, 'extendedCompatibilityIsActivated' integer NOT NULL, FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( `ALTER TABLE 'resources' ADD 'maintenanceModeEnabled' integer DEFAULT false NOT NULL;` ).run(); db.prepare( `ALTER TABLE 'resources' ADD 'maintenanceModeType' text DEFAULT 'forced';` ).run(); db.prepare( `ALTER TABLE 'resources' ADD 'maintenanceTitle' text;` ).run(); db.prepare( `ALTER TABLE 'resources' ADD 'maintenanceMessage' text;` ).run(); db.prepare( `ALTER TABLE 'resources' ADD 'maintenanceEstimatedTime' text;` ).run(); db.prepare( `ALTER TABLE 'siteResources' ADD 'tcpPortRangeString' text DEFAULT '*' NOT NULL;` ).run(); db.prepare( `ALTER TABLE 'siteResources' ADD 'udpPortRangeString' text DEFAULT '*' NOT NULL;` ).run(); db.prepare( `ALTER TABLE 'siteResources' ADD 'disableIcmp' integer NOT NULL DEFAULT false;` ).run(); })(); db.pragma("foreign_keys = ON"); console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.15.0.ts ================================================ import { __DIRNAME, APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; const version = "1.15.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); try { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.prepare( ` CREATE TABLE 'approvals' ( 'approvalId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'timestamp' integer NOT NULL, 'orgId' text NOT NULL, 'clientId' integer, 'userId' text, 'decision' text DEFAULT 'pending' NOT NULL, 'type' text NOT NULL, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('clientId') REFERENCES 'clients'('clientId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'currentFingerprint' ( 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'olmId' text NOT NULL, 'firstSeen' integer NOT NULL, 'lastSeen' integer NOT NULL, 'lastCollectedAt' integer NOT NULL, 'username' text, 'hostname' text, 'platform' text, 'osVersion' text, 'kernelVersion' text, 'arch' text, 'deviceModel' text, 'serialNumber' text, 'platformFingerprint' text, 'biometricsEnabled' integer DEFAULT false NOT NULL, 'diskEncrypted' integer DEFAULT false NOT NULL, 'firewallEnabled' integer DEFAULT false NOT NULL, 'autoUpdatesEnabled' integer DEFAULT false NOT NULL, 'tpmAvailable' integer DEFAULT false NOT NULL, 'windowsAntivirusEnabled' integer DEFAULT false NOT NULL, 'macosSipEnabled' integer DEFAULT false NOT NULL, 'macosGatekeeperEnabled' integer DEFAULT false NOT NULL, 'macosFirewallStealthMode' integer DEFAULT false NOT NULL, 'linuxAppArmorEnabled' integer DEFAULT false NOT NULL, 'linuxSELinuxEnabled' integer DEFAULT false NOT NULL, FOREIGN KEY ('olmId') REFERENCES 'olms'('id') ON UPDATE no action ON DELETE cascade ); ` ).run(); db.prepare( ` CREATE TABLE 'fingerprintSnapshots' ( 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'fingerprintId' integer, 'username' text, 'hostname' text, 'platform' text, 'osVersion' text, 'kernelVersion' text, 'arch' text, 'deviceModel' text, 'serialNumber' text, 'platformFingerprint' text, 'biometricsEnabled' integer DEFAULT false NOT NULL, 'diskEncrypted' integer DEFAULT false NOT NULL, 'firewallEnabled' integer DEFAULT false NOT NULL, 'autoUpdatesEnabled' integer DEFAULT false NOT NULL, 'tpmAvailable' integer DEFAULT false NOT NULL, 'windowsAntivirusEnabled' integer DEFAULT false NOT NULL, 'macosSipEnabled' integer DEFAULT false NOT NULL, 'macosGatekeeperEnabled' integer DEFAULT false NOT NULL, 'macosFirewallStealthMode' integer DEFAULT false NOT NULL, 'linuxAppArmorEnabled' integer DEFAULT false NOT NULL, 'linuxSELinuxEnabled' integer DEFAULT false NOT NULL, 'hash' text NOT NULL, 'collectedAt' integer NOT NULL, FOREIGN KEY ('fingerprintId') REFERENCES 'currentFingerprint'('id') ON UPDATE no action ON DELETE set null ); ` ).run(); db.prepare( ` CREATE TABLE '__new_loginPageBranding' ( 'loginPageBrandingId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'logoUrl' text, 'logoWidth' integer NOT NULL, 'logoHeight' integer NOT NULL, 'primaryColor' text, 'resourceTitle' text NOT NULL, 'resourceSubtitle' text, 'orgTitle' text, 'orgSubtitle' text ); ` ).run(); db.prepare( `INSERT INTO '__new_loginPageBranding'("loginPageBrandingId", "logoUrl", "logoWidth", "logoHeight", "primaryColor", "resourceTitle", "resourceSubtitle", "orgTitle", "orgSubtitle") SELECT "loginPageBrandingId", "logoUrl", "logoWidth", "logoHeight", "primaryColor", "resourceTitle", "resourceSubtitle", "orgTitle", "orgSubtitle" FROM 'loginPageBranding';` ).run(); db.prepare(`DROP TABLE 'loginPageBranding';`).run(); db.prepare( `ALTER TABLE '__new_loginPageBranding' RENAME TO 'loginPageBranding';` ).run(); db.prepare( `ALTER TABLE 'clients' ADD 'archived' integer DEFAULT false NOT NULL;` ).run(); db.prepare( `ALTER TABLE 'clients' ADD 'blocked' integer DEFAULT false NOT NULL;` ).run(); db.prepare(`ALTER TABLE 'clients' ADD 'approvalState' text;`).run(); db.prepare(`ALTER TABLE 'idp' ADD 'tags' text;`).run(); db.prepare( `ALTER TABLE 'olms' ADD 'archived' integer DEFAULT false NOT NULL;` ).run(); db.prepare( `ALTER TABLE 'roles' ADD 'requireDeviceApproval' integer DEFAULT false;` ).run(); })(); db.pragma("foreign_keys = ON"); console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.15.3.ts ================================================ import { __DIRNAME, APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; const version = "1.15.3"; 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); try { db.transaction(() => { db.prepare(`ALTER TABLE 'limits' ADD 'override' integer DEFAULT false;`).run(); db.prepare(`ALTER TABLE 'subscriptionItems' ADD 'featureId' text;`).run(); db.prepare(`ALTER TABLE 'subscriptionItems' ADD 'stripeSubscriptionItemId' text;`).run(); db.prepare(`ALTER TABLE 'subscriptions' ADD 'version' integer;`).run(); db.prepare(`ALTER TABLE 'subscriptions' ADD 'type' text;`).run(); })(); console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.15.4.ts ================================================ import { __DIRNAME, APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; const version = "1.15.4"; 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); try { db.transaction(() => { db.prepare( `ALTER TABLE 'resources' ADD 'postAuthPath' text;` ).run(); })(); console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.16.0.ts ================================================ import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { encrypt } from "@server/lib/crypto"; import { generateCA } from "@server/lib/sshCA"; import Database from "better-sqlite3"; import fs from "fs"; import path from "path"; import yaml from "js-yaml"; const version = "1.16.0"; function getServerSecret(): string { const envSecret = process.env.SERVER_SECRET; const configPath = fs.existsSync(configFilePath1) ? configFilePath1 : fs.existsSync(configFilePath2) ? configFilePath2 : null; // If no config file but an env secret is set, use the env secret directly if (!configPath) { if (envSecret && envSecret.length > 0) { return envSecret; } throw new Error( "Cannot generate org CA keys: no config file found and SERVER_SECRET env var is not set. " + "Expected config.yml or config.yaml in the config directory, or set SERVER_SECRET." ); } const configContent = fs.readFileSync(configPath, "utf8"); const config = yaml.load(configContent) as { server?: { secret?: string }; }; let secret = config?.server?.secret; if (!secret || secret.length === 0) { // Fall back to SERVER_SECRET env var if config does not contain server.secret if (envSecret && envSecret.length > 0) { secret = envSecret; } } if (!secret || secret.length === 0) { throw new Error( "Cannot generate org CA keys: no server.secret in config and SERVER_SECRET env var is not set. " + "Set server.secret in config.yml/config.yaml or set SERVER_SECRET." ); } return secret; } export default async function migration() { console.log(`Running setup script ${version}...`); // Ensure server secret exists before running migration (required for org CA key generation) getServerSecret(); const location = path.join(APP_PATH, "db", "db.sqlite"); const db = new Database(location); try { db.pragma("foreign_keys = OFF"); db.transaction(() => { // Create roundTripMessageTracker table for tracking message round-trips db.prepare( ` CREATE TABLE 'roundTripMessageTracker' ( 'messageId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'clientId' text, 'messageType' text, 'sentAt' integer NOT NULL, 'receivedAt' integer, 'error' text, 'complete' integer DEFAULT 0 NOT NULL ); ` ).run(); // Org SSH CA and billing columns db.prepare(`ALTER TABLE 'orgs' ADD 'sshCaPrivateKey' text;`).run(); db.prepare(`ALTER TABLE 'orgs' ADD 'sshCaPublicKey' text;`).run(); db.prepare(`ALTER TABLE 'orgs' ADD 'isBillingOrg' integer;`).run(); db.prepare(`ALTER TABLE 'orgs' ADD 'billingOrgId' text;`).run(); // Role SSH sudo and unix group columns db.prepare( `ALTER TABLE 'roles' ADD 'sshSudoMode' text DEFAULT 'none';` ).run(); db.prepare( `ALTER TABLE 'roles' ADD 'sshSudoCommands' text DEFAULT '[]';` ).run(); db.prepare( `ALTER TABLE 'roles' ADD 'sshCreateHomeDir' integer DEFAULT 1;` ).run(); db.prepare( `ALTER TABLE 'roles' ADD 'sshUnixGroups' text DEFAULT '[]';` ).run(); // Site resource auth daemon columns db.prepare( `ALTER TABLE 'siteResources' ADD 'authDaemonPort' integer DEFAULT 22123;` ).run(); db.prepare( `ALTER TABLE 'siteResources' ADD 'authDaemonMode' text DEFAULT 'site';` ).run(); // UserOrg PAM username for SSH db.prepare(`ALTER TABLE 'userOrgs' ADD 'pamUsername' text;`).run(); // Set all admin role sudo to "full"; other roles keep default "none" db.prepare( `UPDATE 'roles' SET 'sshSudoMode' = 'full' WHERE isAdmin = 1;` ).run(); })(); db.pragma("foreign_keys = ON"); const orgRows = db.prepare("SELECT orgId FROM orgs").all() as { orgId: string; }[]; // Generate and store encrypted SSH CA keys for all orgs const secret = getServerSecret(); const updateOrgCaKeys = db.prepare( "UPDATE orgs SET sshCaPrivateKey = ?, sshCaPublicKey = ? WHERE orgId = ?" ); const failedOrgIds: string[] = []; for (const row of orgRows) { try { const ca = generateCA(`pangolin-ssh-ca-${row.orgId}`); const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret); updateOrgCaKeys.run( encryptedPrivateKey, ca.publicKeyOpenSSH, row.orgId ); } catch (err) { failedOrgIds.push(row.orgId); console.error( `Error: No CA was generated for organization "${row.orgId}".`, err instanceof Error ? err.message : err ); } } if (orgRows.length > 0) { const succeeded = orgRows.length - failedOrgIds.length; console.log( `Generated and stored SSH CA keys for ${succeeded} org(s).` ); } if (failedOrgIds.length > 0) { console.error( `No CA was generated for ${failedOrgIds.length} organization(s): ${failedOrgIds.join(", ")}` ); } console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.2.0.ts ================================================ import { db } from "../../db/sqlite"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { sql } from "drizzle-orm"; import fs from "fs"; import yaml from "js-yaml"; import path from "path"; import { z } from "zod"; import { fromZodError } from "zod-validation-error"; const version = "1.2.0"; export default async function migration() { console.log(`Running setup script ${version}...`); try { db.transaction((trx) => { trx.run( sql`ALTER TABLE 'resources' ADD 'enabled' integer DEFAULT true NOT NULL;` ); }); console.log(`Migrated database schema`); } catch (e) { console.log("Unable to migrate database schema"); throw e; } try { // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; if (!rawConfig.flags) { rawConfig.flags = {}; } rawConfig.server.resource_access_token_headers = { id: "P-Access-Token-Id", token: "P-Access-Token" }; // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); console.log(`Added new config option: resource_access_token_headers`); } catch (e) { console.log( `Unable to add new config option: resource_access_token_headers. Please add it manually. https://docs.pangolin.net/self-host/advanced/config-file` ); console.error(e); } try { const traefikPath = path.join( APP_PATH, "traefik", "traefik_config.yml" ); const schema = z.object({ experimental: z.object({ plugins: z.object({ badger: z.object({ moduleName: z.string(), version: z.string() }) }) }) }); const traefikFileContents = fs.readFileSync(traefikPath, "utf8"); const traefikConfig = yaml.load(traefikFileContents) as any; const parsedConfig = schema.safeParse(traefikConfig); if (!parsedConfig.success) { throw new Error(fromZodError(parsedConfig.error).toString()); } traefikConfig.experimental.plugins.badger.version = "v1.1.0"; const updatedTraefikYaml = yaml.dump(traefikConfig); fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8"); console.log( "Updated the version of Badger in your Traefik configuration to v1.1.0" ); } catch (e) { console.log( "We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information." ); console.error(e); } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.3.0.ts ================================================ import Database from "better-sqlite3"; import path from "path"; import fs from "fs"; import yaml from "js-yaml"; import { encodeBase32LowerCaseNoPadding } from "@oslojs/encoding"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; const version = "1.3.0"; const location = path.join(APP_PATH, "db", "db.sqlite"); export default async function migration() { console.log(`Running setup script ${version}...`); const db = new Database(location); try { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.exec(` CREATE TABLE 'apiKeyActions' ( 'apiKeyId' text NOT NULL, 'actionId' text NOT NULL, FOREIGN KEY ('apiKeyId') REFERENCES 'apiKeys'('apiKeyId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('actionId') REFERENCES 'actions'('actionId') ON UPDATE no action ON DELETE cascade ); CREATE TABLE 'apiKeyOrg' ( 'apiKeyId' text NOT NULL, 'orgId' text NOT NULL, FOREIGN KEY ('apiKeyId') REFERENCES 'apiKeys'('apiKeyId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); CREATE TABLE 'apiKeys' ( 'apiKeyId' text PRIMARY KEY NOT NULL, 'name' text NOT NULL, 'apiKeyHash' text NOT NULL, 'lastChars' text NOT NULL, 'dateCreated' text NOT NULL, 'isRoot' integer DEFAULT false NOT NULL ); CREATE TABLE 'hostMeta' ( 'hostMetaId' text PRIMARY KEY NOT NULL, 'createdAt' integer NOT NULL ); CREATE TABLE 'idp' ( 'idpId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'name' text NOT NULL, 'type' text NOT NULL, 'defaultRoleMapping' text, 'defaultOrgMapping' text, 'autoProvision' integer DEFAULT false NOT NULL ); CREATE TABLE 'idpOidcConfig' ( 'idpOauthConfigId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'idpId' integer NOT NULL, 'clientId' text NOT NULL, 'clientSecret' text NOT NULL, 'authUrl' text NOT NULL, 'tokenUrl' text NOT NULL, 'identifierPath' text NOT NULL, 'emailPath' text, 'namePath' text, 'scopes' text NOT NULL, FOREIGN KEY ('idpId') REFERENCES 'idp'('idpId') ON UPDATE no action ON DELETE cascade ); CREATE TABLE 'idpOrg' ( 'idpId' integer NOT NULL, 'orgId' text NOT NULL, 'roleMapping' text, 'orgMapping' text, FOREIGN KEY ('idpId') REFERENCES 'idp'('idpId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); CREATE TABLE 'licenseKey' ( 'licenseKeyId' text PRIMARY KEY NOT NULL, 'instanceId' text NOT NULL, 'token' text NOT NULL ); CREATE TABLE '__new_user' ( 'id' text PRIMARY KEY NOT NULL, 'email' text, 'username' text NOT NULL, 'name' text, 'type' text NOT NULL, 'idpId' integer, 'passwordHash' text, 'twoFactorEnabled' integer DEFAULT false NOT NULL, 'twoFactorSecret' text, 'emailVerified' integer DEFAULT false NOT NULL, 'dateCreated' text NOT NULL, 'serverAdmin' integer DEFAULT false NOT NULL, FOREIGN KEY ('idpId') REFERENCES 'idp'('idpId') ON UPDATE no action ON DELETE cascade ); INSERT INTO '__new_user'( "id", "email", "username", "name", "type", "idpId", "passwordHash", "twoFactorEnabled", "twoFactorSecret", "emailVerified", "dateCreated", "serverAdmin" ) SELECT "id", "email", COALESCE("email", 'unknown'), NULL, 'internal', NULL, "passwordHash", "twoFactorEnabled", "twoFactorSecret", "emailVerified", "dateCreated", "serverAdmin" FROM 'user'; DROP TABLE 'user'; ALTER TABLE '__new_user' RENAME TO 'user'; ALTER TABLE 'resources' ADD 'stickySession' integer DEFAULT false NOT NULL; ALTER TABLE 'resources' ADD 'tlsServerName' text; ALTER TABLE 'resources' ADD 'setHostHeader' text; CREATE TABLE 'exitNodes_new' ( 'exitNodeId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'name' text NOT NULL, 'address' text NOT NULL, 'endpoint' text NOT NULL, 'publicKey' text NOT NULL, 'listenPort' integer NOT NULL, 'reachableAt' text ); INSERT INTO 'exitNodes_new' ( 'exitNodeId', 'name', 'address', 'endpoint', 'publicKey', 'listenPort', 'reachableAt' ) SELECT exitNodeId, name, address, endpoint, pubicKey, listenPort, reachableAt FROM exitNodes; DROP TABLE 'exitNodes'; ALTER TABLE 'exitNodes_new' RENAME TO 'exitNodes'; `); })(); // <-- executes the transaction immediately db.pragma("foreign_keys = ON"); console.log(`Migrated database schema`); } catch (e) { console.log("Unable to migrate database schema"); throw e; } // Update config file try { const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; if (!rawConfig.server.secret) { rawConfig.server.secret = generateIdFromEntropySize(32); } const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); console.log(`Added new config option: server.secret`); } catch (e) { console.log( `Unable to add new config option: server.secret. Please add it manually.` ); console.error(e); } console.log(`${version} migration complete`); } function generateIdFromEntropySize(size: number): string { const buffer = crypto.getRandomValues(new Uint8Array(size)); return encodeBase32LowerCaseNoPadding(buffer); } ================================================ FILE: server/setup/scriptsSqlite/1.5.0.ts ================================================ import Database from "better-sqlite3"; import path from "path"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import fs from "fs"; import yaml from "js-yaml"; const version = "1.5.0"; const location = path.join(APP_PATH, "db", "db.sqlite"); export default async function migration() { console.log(`Running setup script ${version}...`); const db = new Database(location); try { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.exec(` ALTER TABLE 'sites' ADD 'dockerSocketEnabled' integer DEFAULT true NOT NULL; `); })(); // <-- executes the transaction immediately db.pragma("foreign_keys = ON"); console.log(`Migrated database schema`); } catch (e) { console.log("Unable to migrate database schema"); throw e; } try { // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; if (rawConfig.cors?.headers) { const headers = JSON.parse(JSON.stringify(rawConfig.cors.headers)); rawConfig.cors.allowed_headers = headers; delete rawConfig.cors.headers; } // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); console.log(`Migrated CORS headers to allowed_headers`); } catch (e) { console.log(`Unable to migrate config file. Error: ${e}`); } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.6.0.ts ================================================ import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import Database from "better-sqlite3"; import fs from "fs"; import yaml from "js-yaml"; import path from "path"; const version = "1.6.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); try { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.exec(` UPDATE 'user' SET email = LOWER(email); UPDATE 'user' SET username = LOWER(username); `); })(); // <-- executes the transaction immediately db.pragma("foreign_keys = ON"); console.log(`Migrated database schema`); } catch (e) { console.log("Unable to make all usernames and emails lowercase"); console.log(e); } try { // Determine which config file exists const filePaths = [configFilePath1, configFilePath2]; let filePath = ""; for (const path of filePaths) { if (fs.existsSync(path)) { filePath = path; break; } } if (!filePath) { throw new Error( `No config file found (expected config.yml or config.yaml).` ); } // Read and parse the YAML file const fileContents = fs.readFileSync(filePath, "utf8"); const rawConfig = yaml.load(fileContents) as any; if (rawConfig.server?.trust_proxy) { rawConfig.server.trust_proxy = 1; } // Write the updated YAML back to the file const updatedYaml = yaml.dump(rawConfig); fs.writeFileSync(filePath, updatedYaml, "utf8"); console.log(`Set trust_proxy to 1 in config file`); } catch (e) { console.log( `Unable to migrate config file. Please do it manually. Error: ${e}` ); } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.7.0.ts ================================================ import { APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; const version = "1.7.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); try { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.exec(` CREATE TABLE 'clientSites' ( 'clientId' integer NOT NULL, 'siteId' integer NOT NULL, 'isRelayed' integer DEFAULT 0 NOT NULL, FOREIGN KEY ('clientId') REFERENCES 'clients'('id') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade ); CREATE TABLE 'clients' ( 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'orgId' text NOT NULL, 'exitNode' integer, 'name' text NOT NULL, 'pubKey' text, 'subnet' text NOT NULL, 'bytesIn' integer, 'bytesOut' integer, 'lastBandwidthUpdate' text, 'lastPing' text, 'type' text NOT NULL, 'online' integer DEFAULT 0 NOT NULL, 'endpoint' text, 'lastHolePunch' integer, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('exitNode') REFERENCES 'exitNodes'('exitNodeId') ON UPDATE no action ON DELETE set null ); CREATE TABLE 'clientSession' ( 'id' text PRIMARY KEY NOT NULL, 'olmId' text NOT NULL, 'expiresAt' integer NOT NULL, FOREIGN KEY ('olmId') REFERENCES 'olms'('id') ON UPDATE no action ON DELETE cascade ); CREATE TABLE 'olms' ( 'id' text PRIMARY KEY NOT NULL, 'secretHash' text NOT NULL, 'dateCreated' text NOT NULL, 'clientId' integer, FOREIGN KEY ('clientId') REFERENCES 'clients'('id') ON UPDATE no action ON DELETE cascade ); CREATE TABLE 'roleClients' ( 'roleId' integer NOT NULL, 'clientId' integer NOT NULL, FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('clientId') REFERENCES 'clients'('id') ON UPDATE no action ON DELETE cascade ); CREATE TABLE 'webauthnCredentials' ( 'credentialId' text PRIMARY KEY NOT NULL, 'userId' text NOT NULL, 'publicKey' text NOT NULL, 'signCount' integer NOT NULL, 'transports' text, 'name' text, 'lastUsed' text NOT NULL, 'dateCreated' text NOT NULL, FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade ); CREATE TABLE 'userClients' ( 'userId' text NOT NULL, 'clientId' integer NOT NULL, FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('clientId') REFERENCES 'clients'('id') ON UPDATE no action ON DELETE cascade ); CREATE TABLE 'userDomains' ( 'userId' text NOT NULL, 'domainId' text NOT NULL, FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE cascade ); CREATE TABLE 'webauthnChallenge' ( 'sessionId' text PRIMARY KEY NOT NULL, 'challenge' text NOT NULL, 'securityKeyName' text, 'userId' text, 'expiresAt' integer NOT NULL, FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade ); `); db.exec(` CREATE TABLE '__new_sites' ( 'siteId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'orgId' text NOT NULL, 'niceId' text NOT NULL, 'exitNode' integer, 'name' text NOT NULL, 'pubKey' text, 'subnet' text, 'bytesIn' integer DEFAULT 0, 'bytesOut' integer DEFAULT 0, 'lastBandwidthUpdate' text, 'type' text NOT NULL, 'online' integer DEFAULT 0 NOT NULL, 'address' text, 'endpoint' text, 'publicKey' text, 'lastHolePunch' integer, 'listenPort' integer, 'dockerSocketEnabled' integer DEFAULT 1 NOT NULL, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('exitNode') REFERENCES 'exitNodes'('exitNodeId') ON UPDATE no action ON DELETE set null ); INSERT INTO '__new_sites' ( 'siteId', 'orgId', 'niceId', 'exitNode', 'name', 'pubKey', 'subnet', 'bytesIn', 'bytesOut', 'lastBandwidthUpdate', 'type', 'online', 'address', 'endpoint', 'publicKey', 'lastHolePunch', 'listenPort', 'dockerSocketEnabled' ) SELECT siteId, orgId, niceId, exitNode, name, pubKey, subnet, bytesIn, bytesOut, lastBandwidthUpdate, type, online, NULL, NULL, NULL, NULL, NULL, dockerSocketEnabled FROM sites; DROP TABLE 'sites'; ALTER TABLE '__new_sites' RENAME TO 'sites'; `); db.exec(` ALTER TABLE 'domains' ADD 'type' text; ALTER TABLE 'domains' ADD 'verified' integer DEFAULT 0 NOT NULL; ALTER TABLE 'domains' ADD 'failed' integer DEFAULT 0 NOT NULL; ALTER TABLE 'domains' ADD 'tries' integer DEFAULT 0 NOT NULL; ALTER TABLE 'exitNodes' ADD 'maxConnections' integer; ALTER TABLE 'newt' ADD 'version' text; ALTER TABLE 'orgs' ADD 'subnet' text; ALTER TABLE 'user' ADD 'twoFactorSetupRequested' integer DEFAULT 0; ALTER TABLE 'resources' DROP COLUMN 'isBaseDomain'; `); })(); db.pragma("foreign_keys = ON"); console.log(`Migrated database schema`); } catch (e) { console.log("Unable to migrate database schema"); throw e; } db.transaction(() => { // Update all existing orgs to have the default subnet db.exec(`UPDATE 'orgs' SET 'subnet' = '100.90.128.0/24'`); // Get all orgs and their sites to assign sequential IP addresses const orgs = db.prepare(`SELECT orgId FROM 'orgs'`).all() as { orgId: string; }[]; for (const org of orgs) { const sites = db .prepare( `SELECT siteId FROM 'sites' WHERE orgId = ? ORDER BY siteId` ) .all(org.orgId) as { siteId: number }[]; let ipIndex = 1; for (const site of sites) { const address = `100.90.128.${ipIndex}/24`; db.prepare( `UPDATE 'sites' SET 'address' = ? WHERE siteId = ?` ).run(address, site.siteId); ipIndex++; } } })(); console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.8.0.ts ================================================ import { APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; const version = "1.8.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); try { db.transaction(() => { db.exec(` ALTER TABLE 'resources' ADD 'enableProxy' integer DEFAULT 1; ALTER TABLE 'sites' ADD 'remoteSubnets' text; ALTER TABLE 'user' ADD 'termsAcceptedTimestamp' text; ALTER TABLE 'user' ADD 'termsVersion' text; `); })(); console.log("Migrated database schema"); } catch (e) { console.log("Unable to migrate database schema"); throw e; } console.log(`${version} migration complete`); } ================================================ FILE: server/setup/scriptsSqlite/1.9.0.ts ================================================ import { APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; const version = "1.9.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); const resourceSiteMap = new Map(); let firstSiteId: number = 1; try { // Get the first siteId to use as default const firstSite = db .prepare("SELECT siteId FROM sites LIMIT 1") .get() as { siteId: number } | undefined; if (firstSite) { firstSiteId = firstSite.siteId; } const resources = db .prepare( "SELECT resourceId, siteId FROM resources WHERE siteId IS NOT NULL" ) .all() as Array<{ resourceId: number; siteId: number }>; for (const resource of resources) { resourceSiteMap.set(resource.resourceId, resource.siteId); } } catch (e) { console.log("Error getting resources:", e); } try { db.pragma("foreign_keys = OFF"); db.transaction(() => { db.exec(`CREATE TABLE 'setupTokens' ( 'tokenId' text PRIMARY KEY NOT NULL, 'token' text NOT NULL, 'used' integer DEFAULT false NOT NULL, 'dateCreated' text NOT NULL, 'dateUsed' text ); --> statement-breakpoint CREATE TABLE 'siteResources' ( 'siteResourceId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'siteId' integer NOT NULL, 'orgId' text NOT NULL, 'name' text NOT NULL, 'protocol' text NOT NULL, 'proxyPort' integer NOT NULL, 'destinationPort' integer NOT NULL, 'destinationIp' text NOT NULL, 'enabled' integer DEFAULT true NOT NULL, FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint PRAGMA foreign_keys=OFF;--> statement-breakpoint CREATE TABLE '__new_resources' ( 'resourceId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'orgId' text NOT NULL, 'name' text NOT NULL, 'subdomain' text, 'fullDomain' text, 'domainId' text, 'ssl' integer DEFAULT false NOT NULL, 'blockAccess' integer DEFAULT false NOT NULL, 'sso' integer DEFAULT true NOT NULL, 'http' integer DEFAULT true NOT NULL, 'protocol' text NOT NULL, 'proxyPort' integer, 'emailWhitelistEnabled' integer DEFAULT false NOT NULL, 'applyRules' integer DEFAULT false NOT NULL, 'enabled' integer DEFAULT true NOT NULL, 'stickySession' integer DEFAULT false NOT NULL, 'tlsServerName' text, 'setHostHeader' text, 'enableProxy' integer DEFAULT true, 'skipToIdpId' integer, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('domainId') REFERENCES 'domains'('domainId') ON UPDATE no action ON DELETE set null, FOREIGN KEY ('skipToIdpId') REFERENCES 'idp'('idpId') ON UPDATE no action ON DELETE cascade ); --> statement-breakpoint INSERT INTO '__new_resources'("resourceId", "orgId", "name", "subdomain", "fullDomain", "domainId", "ssl", "blockAccess", "sso", "http", "protocol", "proxyPort", "emailWhitelistEnabled", "applyRules", "enabled", "stickySession", "tlsServerName", "setHostHeader", "enableProxy", "skipToIdpId") SELECT "resourceId", "orgId", "name", "subdomain", "fullDomain", "domainId", "ssl", "blockAccess", "sso", "http", "protocol", "proxyPort", "emailWhitelistEnabled", "applyRules", "enabled", "stickySession", "tlsServerName", "setHostHeader", "enableProxy", null FROM 'resources';--> statement-breakpoint DROP TABLE 'resources';--> statement-breakpoint ALTER TABLE '__new_resources' RENAME TO 'resources';--> statement-breakpoint PRAGMA foreign_keys=ON;--> statement-breakpoint CREATE TABLE '__new_clients' ( 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, 'orgId' text NOT NULL, 'exitNode' integer, 'name' text NOT NULL, 'pubKey' text, 'subnet' text NOT NULL, 'bytesIn' integer, 'bytesOut' integer, 'lastBandwidthUpdate' text, 'lastPing' integer, 'type' text NOT NULL, 'online' integer DEFAULT false NOT NULL, 'lastHolePunch' integer, FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, FOREIGN KEY ('exitNode') REFERENCES 'exitNodes'('exitNodeId') ON UPDATE no action ON DELETE set null ); --> statement-breakpoint INSERT INTO '__new_clients'("id", "orgId", "exitNode", "name", "pubKey", "subnet", "bytesIn", "bytesOut", "lastBandwidthUpdate", "lastPing", "type", "online", "lastHolePunch") SELECT "id", "orgId", "exitNode", "name", "pubKey", "subnet", "bytesIn", "bytesOut", "lastBandwidthUpdate", NULL, "type", "online", "lastHolePunch" FROM 'clients';--> statement-breakpoint DROP TABLE 'clients';--> statement-breakpoint ALTER TABLE '__new_clients' RENAME TO 'clients';--> statement-breakpoint ALTER TABLE 'clientSites' ADD 'endpoint' text;--> statement-breakpoint ALTER TABLE 'exitNodes' ADD 'online' integer DEFAULT false NOT NULL;--> statement-breakpoint ALTER TABLE 'exitNodes' ADD 'lastPing' integer;--> statement-breakpoint ALTER TABLE 'exitNodes' ADD 'type' text DEFAULT 'gerbil';--> statement-breakpoint ALTER TABLE 'olms' ADD 'version' text;--> statement-breakpoint ALTER TABLE 'orgs' ADD 'createdAt' text;--> statement-breakpoint ALTER TABLE 'targets' ADD 'siteId' integer NOT NULL DEFAULT ${firstSiteId || 1} REFERENCES sites(siteId);`); // for each resource, get all of its targets, and update the siteId to be the previously stored siteId for (const [resourceId, siteId] of resourceSiteMap) { const targets = db .prepare( "SELECT targetId FROM targets WHERE resourceId = ?" ) .all(resourceId) as Array<{ targetId: number }>; for (const target of targets) { db.prepare( "UPDATE targets SET siteId = ? WHERE targetId = ?" ).run(siteId, target.targetId); } } // list resources that have enableProxy false // move them to the siteResources table // remove them from the resources table const proxyFalseResources = db .prepare("SELECT * FROM resources WHERE enableProxy = 0") .all() as Array; for (const resource of proxyFalseResources) { // Get the first target to derive destination IP and port const firstTarget = db .prepare( "SELECT ip, port FROM targets WHERE resourceId = ? LIMIT 1" ) .get(resource.resourceId) as | { ip: string; port: number } | undefined; if (!firstTarget) { continue; } // Insert into siteResources table const stmt = db.prepare(` INSERT INTO siteResources (siteId, orgId, name, protocol, proxyPort, destinationPort, destinationIp, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( resourceSiteMap.get(resource.resourceId), resource.orgId, resource.name, resource.protocol, resource.proxyPort, firstTarget.port, firstTarget.ip, resource.enabled ); // Delete from resources table db.prepare("DELETE FROM resources WHERE resourceId = ?").run( resource.resourceId ); // Delete the targets for this resource db.prepare("DELETE FROM targets WHERE resourceId = ?").run( resource.resourceId ); } })(); db.pragma("foreign_keys = ON"); console.log(`Migrated database`); } catch (e) { console.log("Failed to migrate db:", e); throw e; } } ================================================ FILE: server/types/ArrayElement.ts ================================================ export type ArrayElement = ArrayType extends readonly (infer ElementType)[] ? ElementType : never; ================================================ FILE: server/types/Auth.ts ================================================ import { Request } from "express"; import { User } from "@server/db"; import { Session } from "@server/db"; export interface AuthenticatedRequest extends Request { user: User; session: Session; userOrgRoleId?: number; } ================================================ FILE: server/types/ErrorResponse.ts ================================================ import MessageResponse from "./MessageResponse"; export interface ErrorResponse extends MessageResponse { stack?: string; } export default ErrorResponse; ================================================ FILE: server/types/HttpCode.ts ================================================ export enum HttpCode { CONTINUE = 100, SWITCHING_PROTOCOLS = 101, PROCESSING = 102, EARLY_HINTS = 103, OK = 200, CREATED = 201, ACCEPTED = 202, NON_AUTHORITATIVE_INFORMATION = 203, NO_CONTENT = 204, RESET_CONTENT = 205, PARTIAL_CONTENT = 206, MULTI_STATUS = 207, ALREADY_REPORTED = 208, IM_USED = 226, MULTIPLE_CHOICES = 300, MOVED_PERMANENTLY = 301, FOUND = 302, SEE_OTHER = 303, NOT_MODIFIED = 304, TEMPORARY_REDIRECT = 307, PERMANENT_REDIRECT = 308, BAD_REQUEST = 400, UNAUTHORIZED = 401, PAYMENT_REQUIRED = 402, FORBIDDEN = 403, NOT_FOUND = 404, METHOD_NOT_ALLOWED = 405, NOT_ACCEPTABLE = 406, PROXY_AUTHENTICATION_REQUIRED = 407, REQUEST_TIMEOUT = 408, CONFLICT = 409, GONE = 410, LENGTH_REQUIRED = 411, PRECONDITION_FAILED = 412, CONTENT_TOO_LARGE = 413, URI_TOO_LONG = 414, UNSUPPORTED_MEDIA_TYPE = 415, RANGE_NOT_SATISFIABLE = 416, EXPECTATION_FAILED = 417, IM_A_TEAPOT = 418, MISDIRECTED_REQUEST = 421, UNPROCESSABLE_CONTENT = 422, LOCKED = 423, FAILED_DEPENDENCY = 424, TOO_EARLY = 425, UPGRADE_REQUIRED = 426, PRECONDITION_REQUIRED = 428, TOO_MANY_REQUESTS = 429, REQUEST_HEADER_FIELDS_TOO_LARGE = 431, UNAVAILABLE_FOR_LEGAL_REASONS = 451, INTERNAL_SERVER_ERROR = 500, NOT_IMPLEMENTED = 501, BAD_GATEWAY = 502, SERVICE_UNAVAILABLE = 503, GATEWAY_TIMEOUT = 504, HTTP_VERSION_NOT_SUPPORTED = 505, VARIANT_ALSO_NEGOTIATES = 506, INSUFFICIENT_STORAGE = 507, LOOP_DETECTED = 508, NOT_EXTENDED = 510, NETWORK_AUTHENTICATION_REQUIRED = 511 } export default HttpCode; ================================================ FILE: server/types/MessageResponse.ts ================================================ export interface ResponseT { data: T | null; success: boolean; error: boolean; message: string; status: number; } export default ResponseT; ================================================ FILE: server/types/Pagination.ts ================================================ export type Pagination = { total: number; pageSize: number; page: number }; export type PaginatedResponse = T & { pagination: Pagination; }; ================================================ FILE: server/types/Response.ts ================================================ export interface ResponseT { data: T | null; success: boolean; error: boolean; message: string; status: number; } export default ResponseT; ================================================ FILE: server/types/Tiers.ts ================================================ export type Tier = "tier1" | "tier2" | "tier3" | "enterprise"; ================================================ FILE: server/types/UserTypes.ts ================================================ export enum UserType { Internal = "internal", OIDC = "oidc" } ================================================ FILE: src/actions/server.ts ================================================ "use server"; import { cookies, headers as reqHeaders } from "next/headers"; import { ResponseT } from "@server/types/Response"; import { pullEnv } from "@app/lib/pullEnv"; type CookieOptions = { path?: string; httpOnly?: boolean; secure?: boolean; sameSite?: "lax" | "strict" | "none"; expires?: Date; maxAge?: number; domain?: string; }; function parseSetCookieString( setCookie: string, host?: string ): { name: string; value: string; options: CookieOptions; } { const parts = setCookie.split(";").map((p) => p.trim()); const [nameValue, ...attrParts] = parts; const [name, ...valParts] = nameValue.split("="); const value = valParts.join("="); // handles '=' inside JWT const env = pullEnv(); const options: CookieOptions = {}; for (const attr of attrParts) { const [k, v] = attr.split("=").map((s) => s.trim()); switch (k.toLowerCase()) { case "path": options.path = v; break; case "httponly": options.httpOnly = true; break; case "secure": options.secure = true; break; case "samesite": options.sameSite = v?.toLowerCase() as CookieOptions["sameSite"]; break; case "expires": options.expires = new Date(v); break; case "max-age": options.maxAge = parseInt(v, 10); break; } } if (!options.domain) { const d = host ? host.split(":")[0] // strip port if present : new URL(env.app.dashboardUrl).hostname; if (d) { options.domain = d; } } return { name, value, options }; } async function makeApiRequest( url: string, method: "GET" | "POST", body?: any, additionalHeaders: Record = {} ): Promise> { // Get existing cookies to forward const allCookies = await cookies(); const cookieHeader = allCookies.toString(); const headersList = await reqHeaders(); const host = headersList.get("host"); const xForwardedFor = headersList.get("x-forwarded-for"); const headers: Record = { "Content-Type": "application/json", "X-CSRF-Token": "x-csrf-protection", ...(xForwardedFor ? { "X-Forwarded-For": xForwardedFor } : {}), ...(cookieHeader && { Cookie: cookieHeader }), ...additionalHeaders }; let res: Response; try { res = await fetch(url, { method, headers, body: body ? JSON.stringify(body) : undefined, cache: "no-store" }); } catch (fetchError) { console.error("API request failed:", fetchError); return { data: null, success: false, error: true, message: "Failed to connect to server. Please try again.", status: 0 }; } // Handle Set-Cookie header const rawSetCookie = res.headers.get("set-cookie"); if (rawSetCookie) { try { const { name, value, options } = parseSetCookieString( rawSetCookie, host || undefined ); const allCookies = await cookies(); allCookies.set(name, value, options); } catch (cookieError) { console.error("Failed to parse Set-Cookie header:", cookieError); // Continue without setting cookies rather than failing } } let responseData; try { responseData = await res.json(); } catch (jsonError) { console.error("Failed to parse response JSON:", jsonError); return { data: null, success: false, error: true, message: "Invalid response format from server. Please try again.", status: res.status }; } if (!responseData) { console.error("Invalid response structure:", responseData); return { data: null, success: false, error: true, message: "Invalid response structure from server. Please try again.", status: res.status }; } // If the API returned an error, return the error message if (!res.ok || responseData.error) { return { data: null, success: false, error: true, message: responseData.message || `Server responded with ${res.status}: ${res.statusText}`, status: res.status }; } // Handle successful responses where data can be null if (responseData.success && responseData.data === null) { return { data: null, success: true, error: false, message: responseData.message || "Success", status: res.status }; } if (!responseData.data) { console.error("Invalid response structure:", responseData); return { data: null, success: false, error: true, message: "Invalid response structure from server. Please try again.", status: res.status }; } return { data: responseData.data, success: true, error: false, message: responseData.message || "Success", status: res.status }; } // ============================================================================ // AUTH TYPES AND FUNCTIONS // ============================================================================ export type LoginRequest = { email: string; password: string; code?: string; resourceGuid?: string; }; export type LoginResponse = { useSecurityKey?: boolean; codeRequested?: boolean; emailVerificationRequired?: boolean; twoFactorSetupRequired?: boolean; }; export type SecurityKeyStartRequest = { email?: string; }; export type SecurityKeyStartResponse = { tempSessionId: string; challenge: string; allowCredentials: any[]; timeout: number; rpId: string; userVerification: "required" | "preferred" | "discouraged"; }; export type SecurityKeyVerifyRequest = { credential: any; }; export type SecurityKeyVerifyResponse = { success: boolean; message?: string; }; export async function loginProxy( request: LoginRequest, forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; const url = `http://localhost:${serverPort}/api/v1/auth/login${forceLogin ? "?forceLogin=true" : ""}`; console.log("Making login request to:", url); return await makeApiRequest(url, "POST", request); } export async function securityKeyStartProxy( request: SecurityKeyStartRequest, forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/start${forceLogin ? "?forceLogin=true" : ""}`; console.log("Making security key start request to:", url); return await makeApiRequest(url, "POST", request); } export async function securityKeyVerifyProxy( request: SecurityKeyVerifyRequest, tempSessionId: string, forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/verify${forceLogin ? "?forceLogin=true" : ""}`; console.log("Making security key verify request to:", url); return await makeApiRequest( url, "POST", request, { "X-Temp-Session-Id": tempSessionId } ); } // ============================================================================ // RESOURCE TYPES AND FUNCTIONS // ============================================================================ export type ResourcePasswordRequest = { password: string; }; export type ResourcePasswordResponse = { session?: string; }; export type ResourcePincodeRequest = { pincode: string; }; export type ResourcePincodeResponse = { session?: string; }; export type ResourceWhitelistRequest = { email: string; otp?: string; }; export type ResourceWhitelistResponse = { otpSent?: boolean; session?: string; }; export type ResourceAccessResponse = { success: boolean; message?: string; }; export async function resourcePasswordProxy( resourceId: number, request: ResourcePasswordRequest ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; const url = `http://localhost:${serverPort}/api/v1/auth/resource/${resourceId}/password`; console.log("Making resource password request to:", url); return await makeApiRequest(url, "POST", request); } export async function resourcePincodeProxy( resourceId: number, request: ResourcePincodeRequest ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; const url = `http://localhost:${serverPort}/api/v1/auth/resource/${resourceId}/pincode`; console.log("Making resource pincode request to:", url); return await makeApiRequest(url, "POST", request); } export async function resourceWhitelistProxy( resourceId: number, request: ResourceWhitelistRequest ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; const url = `http://localhost:${serverPort}/api/v1/auth/resource/${resourceId}/whitelist`; console.log("Making resource whitelist request to:", url); return await makeApiRequest( url, "POST", request ); } export async function resourceAccessProxy( resourceId: number ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; const url = `http://localhost:${serverPort}/api/v1/resource/${resourceId}`; console.log("Making resource access request to:", url); return await makeApiRequest(url, "GET"); } // ============================================================================ // IDP TYPES AND FUNCTIONS // ============================================================================ export type GenerateOidcUrlRequest = { redirectUrl: string; }; export type GenerateOidcUrlResponse = { redirectUrl: string; }; export type ValidateOidcUrlCallbackRequest = { code: string; state: string; storedState: string; }; export type ValidateOidcUrlCallbackResponse = { redirectUrl: string; }; export async function validateOidcUrlCallbackProxy( idpId: string, code: string, expectedState: string, stateCookie: string, loginPageId?: number ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; const url = `http://localhost:${serverPort}/api/v1/auth/idp/${idpId}/oidc/validate-callback${loginPageId ? "?loginPageId=" + loginPageId : ""}`; console.log("Making OIDC callback validation request to:", url); return await makeApiRequest(url, "POST", { code: code, state: expectedState, storedState: stateCookie }); } export async function generateOidcUrlProxy( idpId: number, redirect: string, orgId?: string, forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; const queryParams = new URLSearchParams(); if (orgId) { queryParams.append("orgId", orgId); } if (forceLogin) { queryParams.append("forceLogin", "true"); } const queryString = queryParams.toString(); const url = `http://localhost:${serverPort}/api/v1/auth/idp/${idpId}/oidc/generate-url${queryString ? `?${queryString}` : ""}`; console.log("Making OIDC URL generation request to:", url); return await makeApiRequest(url, "POST", { redirectUrl: redirect || "/" }); } ================================================ FILE: src/app/[orgId]/layout.tsx ================================================ import { formatAxiosError, internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { verifySession } from "@app/lib/auth/verifySession"; import { CheckOrgUserAccessResponse, GetOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; import { GetOrgUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { cache } from "react"; import SetLastOrgCookie from "@app/components/SetLastOrgCookie"; import SubscriptionStatusProvider from "@app/providers/SubscriptionStatusProvider"; import { GetOrgSubscriptionResponse } from "@server/routers/billing/types"; import { pullEnv } from "@app/lib/pullEnv"; import { build } from "@server/build"; import OrgPolicyResult from "@app/components/OrgPolicyResult"; import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect"; import SubscriptionViolation from "@app/components/SubscriptionViolation"; export default async function OrgLayout(props: { children: React.ReactNode; params: Promise<{ orgId: string }>; }) { const cookie = await authCookieHeader(); const params = await props.params; const orgId = params.orgId; const env = pullEnv(); if (!orgId) { redirect(`/`); } const getUser = cache(verifySession); const user = await getUser(); if (!user) { redirect(`/`); } let accessRes: CheckOrgUserAccessResponse | null = null; try { const checkOrgAccess = cache(() => internal.get>( `/org/${orgId}/user/${user.userId}/check`, cookie ) ); const res = await checkOrgAccess(); accessRes = res.data.data; } catch (e) { redirect(`/`); } if (!accessRes?.allowed) { // For non-admin users, show the member resources portal let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(async () => internal.get>( `/user/${user.userId}/orgs`, await authCookieHeader() ) ); const res = await getOrgs(); if (res && res.data.data.orgs) { orgs = res.data.data.orgs; } } catch (e) {} return ( ); } let subscriptionStatus = null; if (build === "saas") { try { const getSubscription = cache(() => internal.get>( `/org/${orgId}/billing/subscriptions`, cookie ) ); const subRes = await getSubscription(); subscriptionStatus = subRes.data.data; } catch (error) { // If subscription fetch fails, keep subscriptionStatus as null console.error("Failed to fetch subscription status:", error); } } return ( {props.children} {build === "saas" && } ); } ================================================ FILE: src/app/[orgId]/page.tsx ================================================ import { Layout } from "@app/components/Layout"; import MemberResourcesPortal from "@app/components/MemberResourcesPortal"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { verifySession } from "@app/lib/auth/verifySession"; import { pullEnv } from "@app/lib/pullEnv"; import UserProvider from "@app/providers/UserProvider"; import { ListUserOrgsResponse } from "@server/routers/org"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { cache } from "react"; type OrgPageProps = { params: Promise<{ orgId: string }>; }; export default async function OrgPage(props: OrgPageProps) { const params = await props.params; const orgId = params.orgId; const env = pullEnv(); if (!orgId) { redirect(`/`); } const getUser = cache(verifySession); const user = await getUser(); if (!user) { redirect("/"); } let overview: GetOrgOverviewResponse | undefined; try { const res = await internal.get>( `/org/${orgId}/overview`, await authCookieHeader() ); overview = res.data.data; } catch (e) {} // If user is admin or owner, redirect to settings if (overview?.isAdmin || overview?.isOwner) { redirect(`/${orgId}/settings`); } // For non-admin users, show the member resources portal let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(async () => internal.get>( `/user/${user.userId}/orgs`, await authCookieHeader() ) ); const res = await getOrgs(); if (res && res.data.data.orgs) { orgs = res.data.data.orgs; } } catch (e) {} return ( {overview && } ); } ================================================ FILE: src/app/[orgId]/settings/(private)/access/approvals/page.tsx ================================================ import { ApprovalFeed } from "@app/components/ApprovalFeed"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import ApprovalsBanner from "@app/components/ApprovalsBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import type { ApprovalItem } from "@app/lib/queries"; import OrgProvider from "@app/providers/OrgProvider"; import type { GetOrgResponse } from "@server/routers/org"; import type { ListRolesResponse } from "@server/routers/role"; import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; export interface ApprovalFeedPageProps { params: Promise<{ orgId: string }>; } export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) { const params = await props.params; let org: GetOrgResponse | null = null; const orgRes = await getCachedOrg(params.orgId); if (orgRes && orgRes.status === 200) { org = orgRes.data.data; } // Fetch roles to check if approvals are enabled let hasApprovalsEnabled = false; const rolesRes = await internal .get< AxiosResponse >(`/org/${params.orgId}/roles`, await authCookieHeader()) .catch((e) => {}); if (rolesRes && rolesRes.status === 200) { hasApprovalsEnabled = rolesRes.data.data.roles.some( (role) => role.requireDeviceApproval === true ); } const t = await getTranslations(); return ( <>
); } ================================================ FILE: src/app/[orgId]/settings/(private)/billing/layout.tsx ================================================ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { verifySession } from "@app/lib/auth/verifySession"; import OrgProvider from "@app/providers/OrgProvider"; import OrgUserProvider from "@app/providers/OrgUserProvider"; 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; params: Promise<{ orgId: string }>; }; export default async function BillingSettingsPage({ children, params }: BillingSettingsProps) { const { orgId } = await params; if (build !== "saas") { redirect(`/${orgId}/settings`); } const user = await verifySession(); if (!user) { redirect(`/`); } let orgUser = null; try { const res = await getCachedOrgUser(orgId, user.userId); orgUser = res.data.data; } catch { redirect(`/${orgId}`); } let org = null; try { const res = await getCachedOrg(orgId); org = res.data.data; } catch { redirect(`/${orgId}`); } if (!(org?.org?.isBillingOrg && orgUser?.isOwner)) { redirect(`/${orgId}`); } const t = await getTranslations(); return ( <> {children} ); } ================================================ FILE: src/app/[orgId]/settings/(private)/billing/page.tsx ================================================ "use client"; import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; import { useState, useEffect } from "react"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { formatAxiosError } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { SettingsContainer, SettingsSection, SettingsSectionHeader, SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, SettingsSectionFooter } from "@app/components/Settings"; import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; import { Credenza, CredenzaBody, CredenzaClose, CredenzaContent, CredenzaDescription, CredenzaFooter, CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; import { cn } from "@app/lib/cn"; import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react"; import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert"; import { Tooltip, TooltipTrigger, TooltipContent } from "@app/components/ui/tooltip"; import { GetOrgSubscriptionResponse, GetOrgUsageResponse } from "@server/routers/billing/types"; import { useTranslations } from "use-intl"; import Link from "next/link"; import { Tier } from "@server/types/Tiers"; import { freeLimitSet, tier1LimitSet, tier2LimitSet, tier3LimitSet } from "@server/lib/billing/limitSet"; import { FeatureId } from "@server/lib/billing/features"; // Plan tier definitions matching the mockup type PlanId = "basic" | "home" | "team" | "business" | "enterprise"; type PlanOption = { id: PlanId; name: string; price: string; priceDetail?: string; tierType: Tier | null; features: string[]; }; const planOptions: PlanOption[] = [ { id: "basic", name: "Basic", price: "Free", tierType: null, features: [ "Basic Pangolin features", "Free provided domains", "Web-based proxy resources", "Private resources and clients", "Peer-to-peer connections" ] }, { id: "home", name: "Home", price: "$12.50", priceDetail: "/ month", tierType: "tier1", features: [ "Everything in Basic", "OAuth2/OIDC, Google, & Azure SSO", "Bring your own identity provider", "Pangolin SSH", "Custom branding", "Device admin approvals" ] }, { id: "team", name: "Team", price: "$4", priceDetail: "per user / month", tierType: "tier2", features: [ "Everything in Basic", "Custom domains", "OAuth2/OIDC, Google, & Azure SSO", "Access and action audit logs", "Device posture information" ] }, { id: "business", name: "Business", price: "$9", priceDetail: "per user / month", tierType: "tier3", features: [ "Everything in Team", "Multiple organizations (multi-tenancy)", "Auto-provisioning via IdP", "Pangolin SSH", "Device approvals", "Custom branding", "Business support" ] }, { id: "enterprise", name: "Enterprise", price: "Custom", tierType: null, features: [ "Everything in Business", "Custom limits", "Priority support and SLA", "Log push and export", "Private and Gov-Cloud deployment options", "Dedicated, premium relay/exit nodes", "Pay by invoice " ] } ]; // Tier limits mapping derived from limit sets const tierLimits: Record< Tier | "basic", { 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, 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, 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, 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, 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 organizations: 0 // Custom for enterprise } }; export default function BillingPage() { const { org } = useOrgContext(); const envContext = useEnvContext(); const api = createApiClient(envContext); const t = useTranslations(); // Subscription state const [allSubscriptions, setAllSubscriptions] = useState< GetOrgSubscriptionResponse["subscriptions"] >([]); const [tierSubscription, setTierSubscription] = useState< GetOrgSubscriptionResponse["subscriptions"][0] | null >(null); const [licenseSubscription, setLicenseSubscription] = useState< GetOrgSubscriptionResponse["subscriptions"][0] | null >(null); const [subscriptionLoading, setSubscriptionLoading] = useState(true); // Usage and limits data const [usageData, setUsageData] = useState( [] ); const [limitsData, setLimitsData] = useState( [] ); const [hasSubscription, setHasSubscription] = useState(false); const [isLoading, setIsLoading] = useState(false); const [currentTier, setCurrentTier] = useState(null); // Usage IDs const USERS = "users"; const SITES = "sites"; const DOMAINS = "domains"; const REMOTE_EXIT_NODES = "remoteExitNodes"; const ORGINIZATIONS = "organizations"; // Confirmation dialog state const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [pendingTier, setPendingTier] = useState<{ tier: Tier | "basic"; action: "upgrade" | "downgrade"; planName: string; price: string; } | null>(null); useEffect(() => { async function fetchSubscription() { setSubscriptionLoading(true); try { const res = await api.get< AxiosResponse >(`/org/${org.org.orgId}/billing/subscriptions`); const { subscriptions } = res.data.data; setAllSubscriptions(subscriptions); // Find tier subscription const tierSub = subscriptions.find( ({ subscription }) => subscription?.type === "tier1" || subscription?.type === "tier2" || subscription?.type === "tier3" || subscription?.type === "enterprise" ); setTierSubscription(tierSub || null); if (tierSub?.subscription) { setCurrentTier(tierSub.subscription.type as Tier); setHasSubscription( tierSub.subscription.status === "active" ); } // Find license subscription const licenseSub = subscriptions.find( ({ subscription }) => subscription?.type === "license" ); setLicenseSubscription(licenseSub || null); } catch (error) { toast({ title: t("billingFailedToLoadSubscription"), description: formatAxiosError(error), variant: "destructive" }); } finally { setSubscriptionLoading(false); } } fetchSubscription(); }, [org.org.orgId]); useEffect(() => { async function fetchUsage() { try { const res = await api.get>( `/org/${org.org.orgId}/billing/usage` ); const { usage, limits } = res.data.data; setUsageData(usage); setLimitsData(limits); } catch (error) { toast({ title: t("billingFailedToLoadUsage"), description: formatAxiosError(error), variant: "destructive" }); } } fetchUsage(); }, [org.org.orgId]); const handleStartSubscription = async (tier: Tier) => { setIsLoading(true); try { const response = await api.post>( `/org/${org.org.orgId}/billing/create-checkout-session`, { tier } ); const checkoutUrl = response.data.data; if (checkoutUrl) { window.location.href = checkoutUrl; } else { toast({ title: t("billingFailedToGetCheckoutUrl"), description: t("billingPleaseTryAgainLater"), variant: "destructive" }); setIsLoading(false); } } catch (error) { toast({ title: t("billingCheckoutError"), description: formatAxiosError(error), variant: "destructive" }); setIsLoading(false); } }; const handleModifySubscription = async () => { setIsLoading(true); try { const response = await api.post>( `/org/${org.org.orgId}/billing/create-portal-session`, {} ); const portalUrl = response.data.data; if (portalUrl) { window.location.href = portalUrl; } else { toast({ title: t("billingFailedToGetPortalUrl"), description: t("billingPleaseTryAgainLater"), variant: "destructive" }); setIsLoading(false); } } catch (error) { toast({ title: t("billingPortalError"), description: formatAxiosError(error), variant: "destructive" }); setIsLoading(false); } }; const handleChangeTier = async (tier: Tier) => { if (!hasSubscription) { // If no subscription, start a new one handleStartSubscription(tier); return; } setIsLoading(true); try { await api.post(`/org/${org.org.orgId}/billing/change-tier`, { tier }); // Poll the API to check if the tier change has been reflected const pollForTierChange = async (targetTier: Tier) => { const maxAttempts = 30; // 30 seconds with 1 second interval let attempts = 0; const poll = async (): Promise => { try { const res = await api.get< AxiosResponse >(`/org/${org.org.orgId}/billing/subscriptions`); const { subscriptions } = res.data.data; // Find tier subscription const tierSub = subscriptions.find( ({ subscription }) => subscription?.type === "tier1" || subscription?.type === "tier2" || subscription?.type === "tier3" ); // Check if the tier has changed to the target tier if (tierSub?.subscription?.type === targetTier) { return true; } return false; } catch (error) { console.error("Error polling subscription:", error); return false; } }; while (attempts < maxAttempts) { const success = await poll(); if (success) { // Tier change reflected, refresh the page window.location.reload(); return; } attempts++; if (attempts < maxAttempts) { // Wait 1 second before next poll await new Promise((resolve) => setTimeout(resolve, 1000) ); } } // If we've exhausted all attempts, show an error toast({ title: "Tier change processing", description: "Your tier change is taking longer than expected. Please refresh the page in a moment to see the changes.", variant: "destructive" }); setIsLoading(false); }; // Start polling for the tier change pollForTierChange(tier); } catch (error) { toast({ title: "Failed to change tier", description: formatAxiosError(error), variant: "destructive" }); setIsLoading(false); } }; const confirmTierChange = () => { if (!pendingTier) return; if ( pendingTier.action === "upgrade" || pendingTier.action === "downgrade" ) { // If downgrading to basic (free tier), go to Stripe portal if (pendingTier.tier === "basic") { handleModifySubscription(); } else if (hasSubscription) { handleChangeTier(pendingTier.tier); } else { handleStartSubscription(pendingTier.tier); } } // setShowConfirmDialog(false); // setPendingTier(null); }; const showTierConfirmation = ( tier: Tier | "basic", action: "upgrade" | "downgrade", planName: string, price: string ) => { setPendingTier({ tier, action, planName, price }); setShowConfirmDialog(true); }; const handleContactUs = () => { window.open("https://pangolin.net/talk-to-us", "_blank"); }; // Get current plan ID from tier const getCurrentPlanId = (): PlanId => { if (!hasSubscription || !currentTier) return "basic"; // Handle enterprise subscription type directly if (currentTier === "enterprise") return "enterprise"; const plan = planOptions.find((p) => p.tierType === currentTier); return plan?.id || "basic"; }; const currentPlanId = getCurrentPlanId(); // Check if subscription is in a problematic state that requires attention const hasProblematicSubscription = (): boolean => { if (!tierSubscription?.subscription) return false; const status = tierSubscription.subscription.status; return ( status === "past_due" || status === "unpaid" || status === "incomplete" || status === "incomplete_expired" ); }; const isProblematicState = hasProblematicSubscription(); // Get user-friendly subscription status message const getSubscriptionStatusMessage = (): { title: string; description: string; } | null => { if (!tierSubscription?.subscription || !isProblematicState) return null; const status = tierSubscription.subscription.status; switch (status) { case "past_due": return { title: t("billingPastDueTitle") || "Payment Past Due", description: t("billingPastDueDescription") || "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier." }; case "unpaid": return { title: t("billingUnpaidTitle") || "Subscription Unpaid", description: t("billingUnpaidDescription") || "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription." }; case "incomplete": return { title: t("billingIncompleteTitle") || "Payment Incomplete", description: t("billingIncompleteDescription") || "Your payment is incomplete. Please complete the payment process to activate your subscription." }; case "incomplete_expired": return { title: t("billingIncompleteExpiredTitle") || "Payment Expired", description: t("billingIncompleteExpiredDescription") || "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features." }; default: return null; } }; const statusMessage = getSubscriptionStatusMessage(); // Get button label and action for each plan const getPlanAction = (plan: PlanOption) => { if (plan.id === "enterprise") { return { label: "Contact Us", action: handleContactUs, variant: "outline" as const, disabled: false }; } if (plan.id === currentPlanId) { // If it's the basic plan (basic with no subscription), show as current but disabled if ( plan.id === "basic" && !hasSubscription && !isProblematicState ) { return { label: "Current Plan", action: () => {}, variant: "default" as const, disabled: true }; } // If on free tier but has a problematic subscription, allow them to manage it if (plan.id === "basic" && isProblematicState) { return { label: "Manage Subscription", action: handleModifySubscription, variant: "default" as const, disabled: false }; } return { label: "Manage Current Plan", action: handleModifySubscription, variant: "default" as const, disabled: false }; } const currentIndex = planOptions.findIndex( (p) => p.id === currentPlanId ); const planIndex = planOptions.findIndex((p) => p.id === plan.id); if (planIndex < currentIndex) { return { label: "Downgrade", action: () => { if (plan.tierType) { showTierConfirmation( plan.tierType, "downgrade", plan.name, plan.price + (" " + plan.priceDetail || "") ); } else if (plan.id === "basic") { // Show confirmation for downgrading to basic (free tier) showTierConfirmation( "basic", "downgrade", plan.name, plan.price ); } else { handleModifySubscription(); } }, variant: "outline" as const, disabled: isProblematicState }; } return { label: "Upgrade", action: () => { if (plan.tierType) { showTierConfirmation( plan.tierType, "upgrade", plan.name, plan.price + (" " + plan.priceDetail || "") ); } else { handleModifySubscription(); } }, variant: "outline" as const, disabled: isProblematicState }; }; // Get usage value by feature ID const getUsageValue = (featureId: string): number => { const usage = usageData.find((u) => u.featureId === featureId); return usage?.instantaneousValue || usage?.latestValue || 0; }; // Get limit value by feature ID const getLimitValue = (featureId: string): number | null => { const limit = limitsData.find((l) => l.featureId === featureId); return limit?.value ?? null; }; // Check if usage exceeds limit for a specific feature const isOverLimit = (featureId: string): boolean => { const usage = getUsageValue(featureId); const limit = getLimitValue(featureId); return limit !== null && usage > limit; }; // Calculate current usage cost for display const getUserCount = () => getUsageValue(USERS); const getPricePerUser = () => { if (!tierSubscription?.items) return 0; // Find the subscription item for USERS feature const usersItem = tierSubscription.items.find( (item) => item.featureId === USERS ); console.log("Users subscription item:", usersItem); // unitAmount is in cents, convert to dollars if (usersItem?.unitAmount) { return usersItem.unitAmount / 100; } return 0; }; // Get license key count const getLicenseKeyCount = (): number => { if (!licenseSubscription?.items) return 0; return licenseSubscription.items.length; }; // Check if downgrading to a tier would violate current usage limits const checkLimitViolations = ( targetTier: Tier | "basic" ): Array<{ feature: string; currentUsage: number; newLimit: number; }> => { const violations: Array<{ feature: string; currentUsage: number; newLimit: number; }> = []; const limits = tierLimits[targetTier]; // Check users const usersUsage = getUsageValue(USERS); if (limits.users > 0 && usersUsage > limits.users) { violations.push({ feature: "Users", currentUsage: usersUsage, newLimit: limits.users }); } // Check sites const sitesUsage = getUsageValue(SITES); if (limits.sites > 0 && sitesUsage > limits.sites) { violations.push({ feature: "Sites", currentUsage: sitesUsage, newLimit: limits.sites }); } // Check domains const domainsUsage = getUsageValue(DOMAINS); if (limits.domains > 0 && domainsUsage > limits.domains) { violations.push({ feature: "Domains", currentUsage: domainsUsage, newLimit: limits.domains }); } // Check remote nodes const remoteNodesUsage = getUsageValue(REMOTE_EXIT_NODES); if (limits.remoteNodes > 0 && remoteNodesUsage > limits.remoteNodes) { violations.push({ feature: "Remote Exit Nodes", currentUsage: remoteNodesUsage, newLimit: limits.remoteNodes }); } // Check organizations const organizationsUsage = getUsageValue(ORGINIZATIONS); if ( limits.organizations > 0 && organizationsUsage > limits.organizations ) { violations.push({ feature: "Organizations", currentUsage: organizationsUsage, newLimit: limits.organizations }); } return violations; }; if (subscriptionLoading) { return (
{t("billingLoadingSubscription")}
); } return ( {/* Subscription Status Alert */} {isProblematicState && statusMessage && ( {statusMessage.title} {statusMessage.description}{" "} )} {/* Your Plan Section */} {t("billingYourPlan") || "Your Plan"} {t("billingViewOrModifyPlan") || "View or modify your current plan"} {/* Plan Cards Grid */}
{planOptions.map((plan) => { const isCurrentPlan = plan.id === currentPlanId; const planAction = getPlanAction(plan); return (
{plan.name}
{plan.price} {plan.priceDetail && ( {plan.priceDetail} )}
{isProblematicState && planAction.disabled && !isCurrentPlan && plan.id !== "enterprise" ? (

{t( "billingResolvePaymentIssue" ) || "Please resolve your payment issue before upgrading or downgrading"}

) : ( )}
); })}
{/* Usage and Limits Section */} {t("billingUsageAndLimits") || "Usage and Limits"} {t("billingViewUsageAndLimits") || "View your plan's limits and current usage"}
{/* Current Usage */}
{t("billingCurrentUsage") || "Current Usage"}
{getUserCount()} {t("billingUsers") || "Users"} {hasSubscription && getPricePerUser() > 0 && (
x ${getPricePerUser()} / month = $ {getUserCount() * getPricePerUser()} / month
)}
{/* Maximum Limits */}
{t("billingMaximumLimits") || "Maximum Limits"}
{t("billingUsers") || "Users"} {isOverLimit(USERS) ? ( {getLimitValue(USERS) ?? t( "billingUnlimited" ) ?? "∞"}{" "} {getLimitValue( USERS ) !== null && "users"}

{t( "billingUsageExceedsLimit", { current: getUsageValue( USERS ), limit: getLimitValue( USERS ) ?? 0 } ) || `Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}

) : ( <> {getLimitValue(USERS) ?? t("billingUnlimited") ?? "∞"}{" "} {getLimitValue(USERS) !== null && "users"} )}
{t("billingSites") || "Sites"} {isOverLimit(SITES) ? ( {getLimitValue(SITES) ?? t( "billingUnlimited" ) ?? "∞"}{" "} {getLimitValue( SITES ) !== null && "sites"}

{t( "billingUsageExceedsLimit", { current: getUsageValue( SITES ), limit: getLimitValue( SITES ) ?? 0 } ) || `Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}

) : ( <> {getLimitValue(SITES) ?? t("billingUnlimited") ?? "∞"}{" "} {getLimitValue(SITES) !== null && "sites"} )}
{t("billingDomains") || "Domains"} {isOverLimit(DOMAINS) ? ( {getLimitValue( DOMAINS ) ?? t( "billingUnlimited" ) ?? "∞"}{" "} {getLimitValue( DOMAINS ) !== null && "domains"}

{t( "billingUsageExceedsLimit", { current: getUsageValue( DOMAINS ), limit: getLimitValue( DOMAINS ) ?? 0 } ) || `Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}

) : ( <> {getLimitValue(DOMAINS) ?? t("billingUnlimited") ?? "∞"}{" "} {getLimitValue(DOMAINS) !== null && "domains"} )}
{t("billingOrganizations") || "Organizations"} {isOverLimit(ORGINIZATIONS) ? ( {getLimitValue( ORGINIZATIONS ) ?? t( "billingUnlimited" ) ?? "∞"}{" "} {getLimitValue( ORGINIZATIONS ) !== null && "orgs"}

{t( "billingUsageExceedsLimit", { current: getUsageValue( ORGINIZATIONS ), limit: getLimitValue( ORGINIZATIONS ) ?? 0 } ) || `Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}

) : ( <> {getLimitValue(ORGINIZATIONS) ?? t("billingUnlimited") ?? "∞"}{" "} {getLimitValue( ORGINIZATIONS ) !== null && "orgs"} )}
{t("billingRemoteNodes") || "Remote Nodes"} {isOverLimit(REMOTE_EXIT_NODES) ? ( {getLimitValue( REMOTE_EXIT_NODES ) ?? t( "billingUnlimited" ) ?? "∞"}{" "} {getLimitValue( REMOTE_EXIT_NODES ) !== null && "nodes"}

{t( "billingUsageExceedsLimit", { current: getUsageValue( REMOTE_EXIT_NODES ), limit: getLimitValue( REMOTE_EXIT_NODES ) ?? 0 } ) || `Current usage (${getUsageValue(REMOTE_EXIT_NODES)}) exceeds limit (${getLimitValue(REMOTE_EXIT_NODES)})`}

) : ( <> {getLimitValue( REMOTE_EXIT_NODES ) ?? t("billingUnlimited") ?? "∞"}{" "} {getLimitValue( REMOTE_EXIT_NODES ) !== null && "nodes"} )}
{/* Paid License Keys Section */} {(licenseSubscription || getLicenseKeyCount() > 0) && ( {t("billingPaidLicenseKeys") || "Paid License Keys"} {t("billingManageLicenseSubscription") || "Manage your subscription for paid self-hosted license keys"}
{t("billingCurrentKeys") || "Current Keys"}
{getLicenseKeyCount()} {getLicenseKeyCount() === 1 ? "key" : "keys"}
)} {/* Tier Change Confirmation Dialog */} {pendingTier?.action === "upgrade" ? t("billingConfirmUpgrade") || "Confirm Upgrade" : t("billingConfirmDowngrade") || "Confirm Downgrade"} {pendingTier?.action === "upgrade" ? t("billingConfirmUpgradeDescription") || `You are about to upgrade to the ${pendingTier?.planName} plan.` : t("billingConfirmDowngradeDescription") || `You are about to downgrade to the ${pendingTier?.planName} plan.`} {pendingTier && pendingTier.tier && (
{pendingTier.planName}
{pendingTier.price}
{/* Features with check marks */} {(() => { const plan = planOptions.find( (p) => p.tierType === pendingTier.tier || (pendingTier.tier === "basic" && p.id === "basic") ); return plan?.features?.length ? (

{"What's included:"}

{plan.features.map( (feature, i) => (
{feature}
) )}
) : null; })()} {/* Limits without check marks */} {tierLimits[pendingTier.tier] && (

{"Up to:"}

{ tierLimits[ pendingTier.tier ].users }{" "} {t("billingUsers") || "Users"}
{ tierLimits[ pendingTier.tier ].sites }{" "} {t("billingSites") || "Sites"}
{ tierLimits[ pendingTier.tier ].domains }{" "} {t("billingDomains") || "Domains"}
{ tierLimits[ pendingTier.tier ].organizations }{" "} {t( "billingOrganizations" ) || "Organizations"}
{ tierLimits[ pendingTier.tier ].remoteNodes }{" "} {t("billingRemoteNodes") || "Remote Nodes"}
)} {/* Warning for limit violations when downgrading */} {pendingTier.action === "downgrade" && (() => { const violations = checkLimitViolations( pendingTier.tier ); if (violations.length > 0) { return ( {t( "billingLimitViolationWarning" ) || "Usage Exceeds New Plan Limits"}

{t( "billingLimitViolationDescription" ) || "Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}

    {violations.map( ( violation, index ) => (
  • { violation.feature } : Currently using{" "} { violation.currentUsage } , new limit is{" "} { violation.newLimit }
  • ) )}
); } return null; })()} {/* Warning for feature loss when downgrading */} {pendingTier.action === "downgrade" && ( {t("billingFeatureLossWarning") || "Feature Availability Notice"} {t( "billingFeatureLossDescription" ) || "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available."} )}
)}
); } ================================================ FILE: src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx ================================================ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button } from "@app/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { useForm } from "react-hook-form"; import { toast } from "@app/hooks/useToast"; import { useRouter, useParams, redirect } from "next/navigation"; import { SettingsContainer, SettingsSection, SettingsSectionHeader, SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, SettingsSectionForm, SettingsSectionFooter, SettingsSectionGrid } from "@app/components/Settings"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useState, useEffect } from "react"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon, ExternalLink } from "lucide-react"; import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; import CopyToClipboard from "@app/components/CopyToClipboard"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; import { useTranslations } from "next-intl"; import { AxiosResponse } from "axios"; import { ListRolesResponse } from "@server/routers/role"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; export default function GeneralPage() { const { env } = useEnvContext(); const api = createApiClient({ env }); const router = useRouter(); const { idpId, orgId } = useParams(); const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roleMappingMode, setRoleMappingMode] = useState< "role" | "expression" >("role"); const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; const [redirectUrl, setRedirectUrl] = useState( `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback` ); const t = useTranslations(); // OIDC form schema (full configuration) const OidcFormSchema = z.object({ name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), clientId: z.string().min(1, { message: t("idpClientIdRequired") }), clientSecret: z .string() .min(1, { message: t("idpClientSecretRequired") }), roleMapping: z.string().nullable().optional(), roleId: z.number().nullable().optional(), authUrl: z.url({ message: t("idpErrorAuthUrlInvalid") }), tokenUrl: z.url({ message: t("idpErrorTokenUrlInvalid") }), identifierPath: z.string().min(1, { message: t("idpPathRequired") }), emailPath: z.string().nullable().optional(), namePath: z.string().nullable().optional(), scopes: z.string().min(1, { message: t("idpScopeRequired") }), autoProvision: z.boolean().default(false) }); // Google form schema (simplified) const GoogleFormSchema = z.object({ name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), clientId: z.string().min(1, { message: t("idpClientIdRequired") }), clientSecret: z .string() .min(1, { message: t("idpClientSecretRequired") }), roleMapping: z.string().nullable().optional(), roleId: z.number().nullable().optional(), autoProvision: z.boolean().default(false) }); // Azure form schema (simplified with tenant ID) const AzureFormSchema = z.object({ name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), clientId: z.string().min(1, { message: t("idpClientIdRequired") }), clientSecret: z .string() .min(1, { message: t("idpClientSecretRequired") }), tenantId: z.string().min(1, { message: t("idpTenantIdRequired") }), roleMapping: z.string().nullable().optional(), roleId: z.number().nullable().optional(), autoProvision: z.boolean().default(false) }); type OidcFormValues = z.infer; type GoogleFormValues = z.infer; type AzureFormValues = z.infer; type GeneralFormValues = | OidcFormValues | GoogleFormValues | AzureFormValues; // Get the appropriate schema based on variant const getFormSchema = () => { switch (variant) { case "google": return GoogleFormSchema; case "azure": return AzureFormSchema; default: return OidcFormSchema; } }; const form = useForm({ resolver: zodResolver(getFormSchema()) as any, // is this right? defaultValues: { name: "", clientId: "", clientSecret: "", authUrl: "", tokenUrl: "", identifierPath: "sub", emailPath: "email", namePath: "name", scopes: "openid profile email", autoProvision: true, roleMapping: null, roleId: null, tenantId: "" } }); // Update form resolver when variant changes useEffect(() => { form.clearErrors(); // Note: We can't change the resolver dynamically, so we'll handle validation in onSubmit }, [variant]); useEffect(() => { async function fetchRoles() { const res = await api .get>(`/org/${orgId}/roles`) .catch((e) => { console.error(e); toast({ variant: "destructive", title: t("accessRoleErrorFetch"), description: formatAxiosError( e, t("accessRoleErrorFetchDescription") ) }); }); if (res?.status === 200) { setRoles(res.data.data.roles); } } const loadIdp = async ( availableRoles: { roleId: number; name: string }[] ) => { try { const res = await api.get(`/org/${orgId}/idp/${idpId}`); if (res.status === 200) { const data = res.data.data; const roleMapping = data.idpOrg.roleMapping; const idpVariant = data.idpOidcConfig?.variant || "oidc"; setRedirectUrl(res.data.data.redirectUrl); // Set the variant setVariant(idpVariant as "oidc" | "google" | "azure"); // Check if roleMapping matches the basic pattern '{role name}' (simple single role) // This should NOT match complex expressions like 'Admin' || 'Member' const isBasicRolePattern = roleMapping && typeof roleMapping === "string" && /^'[^']+'$/.test(roleMapping); // Determine if roleMapping is a number (roleId) or matches basic pattern const isRoleId = !isNaN(Number(roleMapping)) && roleMapping !== ""; const isRoleName = isBasicRolePattern; // Extract role name from basic pattern for matching let extractedRoleName = null; if (isRoleName) { extractedRoleName = roleMapping.slice(1, -1); // Remove quotes } // Try to find matching role by name if we have a basic pattern let matchingRoleId = undefined; if (extractedRoleName && availableRoles.length > 0) { const matchingRole = availableRoles.find( (role) => role.name === extractedRoleName ); if (matchingRole) { matchingRoleId = matchingRole.roleId; } } // Extract tenant ID from Azure URLs if present let tenantId = ""; if (idpVariant === "azure" && data.idpOidcConfig?.authUrl) { // Azure URL format: https://login.microsoftonline.com/{tenant-id}/oauth2/v2.0/authorize const tenantMatch = data.idpOidcConfig.authUrl.match( /login\.microsoftonline\.com\/([^\/]+)\/oauth2/ ); if (tenantMatch) { tenantId = tenantMatch[1]; } } // Reset form with appropriate data based on variant const formData: any = { name: data.idp.name, clientId: data.idpOidcConfig.clientId, clientSecret: data.idpOidcConfig.clientSecret, autoProvision: data.idp.autoProvision, roleMapping: roleMapping || null, roleId: isRoleId ? Number(roleMapping) : matchingRoleId || null }; // Add variant-specific fields if (idpVariant === "oidc") { formData.authUrl = data.idpOidcConfig.authUrl; formData.tokenUrl = data.idpOidcConfig.tokenUrl; formData.identifierPath = data.idpOidcConfig.identifierPath; formData.emailPath = data.idpOidcConfig.emailPath || null; formData.namePath = data.idpOidcConfig.namePath || null; formData.scopes = data.idpOidcConfig.scopes; } else if (idpVariant === "azure") { formData.tenantId = tenantId; } form.reset(formData); // Set the role mapping mode based on the data // Default to "expression" unless it's a simple roleId or basic '{role name}' pattern setRoleMappingMode( matchingRoleId && isRoleName ? "role" : "expression" ); } } catch (e) { toast({ title: t("error"), description: formatAxiosError(e), variant: "destructive" }); router.push(`/${orgId}/settings/idp`); } finally { setInitialLoading(false); } }; const loadData = async () => { const rolesRes = await api .get>(`/org/${orgId}/roles`) .catch((e) => { console.error(e); toast({ variant: "destructive", title: t("accessRoleErrorFetch"), description: formatAxiosError( e, t("accessRoleErrorFetchDescription") ) }); return null; }); const availableRoles = rolesRes?.status === 200 ? rolesRes.data.data.roles : []; setRoles(availableRoles); await loadIdp(availableRoles); }; loadData(); }, []); async function onSubmit(data: GeneralFormValues) { setLoading(true); try { // Validate against the correct schema based on variant const schema = getFormSchema(); const validationResult = schema.safeParse(data); if (!validationResult.success) { // Set form errors const errors = validationResult.error.flatten().fieldErrors; Object.keys(errors).forEach((key) => { const fieldName = key as keyof GeneralFormValues; const errorMessage = (errors as any)[key]?.[0] || t("invalidValue"); form.setError(fieldName, { type: "manual", message: errorMessage }); }); setLoading(false); return; } const roleName = roles.find((r) => r.roleId === data.roleId)?.name; // Build payload based on variant let payload: any = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, autoProvision: data.autoProvision, roleMapping: roleMappingMode === "role" ? `'${roleName}'` : data.roleMapping || "" }; // Add variant-specific fields if (variant === "oidc") { const oidcData = data as OidcFormValues; payload = { ...payload, authUrl: oidcData.authUrl, tokenUrl: oidcData.tokenUrl, identifierPath: oidcData.identifierPath, emailPath: oidcData.emailPath || "", namePath: oidcData.namePath || "", scopes: oidcData.scopes }; } else if (variant === "azure") { const azureData = data as AzureFormValues; // Construct URLs dynamically for Azure provider const authUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/authorize`; const tokenUrl = `https://login.microsoftonline.com/${azureData.tenantId}/oauth2/v2.0/token`; payload = { ...payload, authUrl: authUrl, tokenUrl: tokenUrl, identifierPath: "email", emailPath: "email", namePath: "name", scopes: "openid profile email" }; } else if (variant === "google") { // Google uses predefined URLs payload = { ...payload, authUrl: "https://accounts.google.com/o/oauth2/v2/auth", tokenUrl: "https://oauth2.googleapis.com/token", identifierPath: "email", emailPath: "email", namePath: "name", scopes: "openid profile email" }; } const res = await api.post( `/org/${orgId}/idp/${idpId}/oidc`, payload ); if (res.status === 200) { toast({ title: t("success"), description: t("idpUpdatedDescription") }); router.refresh(); } } catch (e) { toast({ title: t("error"), description: formatAxiosError(e), variant: "destructive" }); } finally { setLoading(false); } } if (initialLoading) { return null; } return ( <> {t("idpTitle")} {t("idpSettingsDescription")} {t("orgIdpRedirectUrls")} {redirectUrl !== dashboardRedirectUrl && ( )} {t("redirectUrlAbout")} {t("redirectUrlAboutDescription")} {/* IDP Type Indicator */}
{t("idpTypeLabel")}:
( {t("name")} {t("idpDisplayName")} )} />
{/* Auto Provision Settings */} {t("idpAutoProvisionUsers")} {t("idpAutoProvisionUsersDescription")}
{ form.setValue( "autoProvision", checked ); }} roleMappingMode={roleMappingMode} onRoleMappingModeChange={(data) => { setRoleMappingMode(data); // Clear roleId and roleMapping when mode changes form.setValue("roleId", null); form.setValue("roleMapping", null); }} roles={roles} roleIdFieldName="roleId" roleMappingFieldName="roleMapping" />
{/* Google Configuration */} {variant === "google" && ( {t("idpGoogleConfiguration")} {t("idpGoogleConfigurationDescription")}
( {t("idpClientId")} {t( "idpGoogleClientIdDescription" )} )} /> ( {t("idpClientSecret")} {t( "idpGoogleClientSecretDescription" )} )} />
)} {/* Azure Configuration */} {variant === "azure" && ( {t("idpAzureConfiguration")} {t("idpAzureConfigurationDescription")}
( {t("idpTenantId")} {t( "idpAzureTenantIdDescription" )} )} /> ( {t("idpClientId")} {t( "idpAzureClientIdDescription" )} )} /> ( {t("idpClientSecret")} {t( "idpAzureClientSecretDescription" )} )} />
)} {/* OIDC Configuration */} {variant === "oidc" && ( {t("idpOidcConfigure")} {t("idpOidcConfigureDescription")}
( {t("idpClientId")} {t( "idpClientIdDescription" )} )} /> ( {t( "idpClientSecret" )} {t( "idpClientSecretDescription" )} )} /> ( {t("idpAuthUrl")} {t( "idpAuthUrlDescription" )} )} /> ( {t("idpTokenUrl")} {t( "idpTokenUrlDescription" )} )} />
{t("idpToken")} {t("idpTokenDescription")}
{t("idpJmespathAbout")} {t( "idpJmespathAboutDescription" )}{" "} {t( "idpJmespathAboutDescriptionLink" )}{" "} ( {t( "idpJmespathLabel" )} {t( "idpJmespathLabelDescription" )} )} /> ( {t( "idpJmespathEmailPathOptional" )} {t( "idpJmespathEmailPathOptionalDescription" )} )} /> ( {t( "idpJmespathNamePathOptional" )} {t( "idpJmespathNamePathOptionalDescription" )} )} /> ( {t( "idpOidcConfigureScopes" )} {t( "idpOidcConfigureScopesDescription" )} )} />
)}
); } ================================================ FILE: src/app/[orgId]/settings/(private)/idp/[idpId]/layout.tsx ================================================ import { internal } from "@app/lib/api"; import { GetIdpResponse as GetOrgIdpResponse } from "@server/routers/idp"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; interface SettingsLayoutProps { children: React.ReactNode; params: Promise<{ orgId: string; idpId: string }>; } export default async function SettingsLayout(props: SettingsLayoutProps) { const params = await props.params; const { children } = props; const t = await getTranslations(); let idp = null; try { const res = await internal.get>( `/org/${params.orgId}/idp/${params.idpId}`, await authCookieHeader() ); idp = res.data.data; } catch { redirect(`/${params.orgId}/settings/idp`); } const navItems: TabItem[] = [ { title: t("general"), href: `/${params.orgId}/settings/idp/${params.idpId}/general` } ]; return ( <>
{children}
); } ================================================ FILE: src/app/[orgId]/settings/(private)/idp/[idpId]/page.tsx ================================================ import { redirect } from "next/navigation"; export default async function IdpPage(props: { params: Promise<{ orgId: string; idpId: string }>; }) { const params = await props.params; redirect(`/${params.orgId}/settings/idp/${params.idpId}/general`); } ================================================ FILE: src/app/[orgId]/settings/(private)/idp/create/page.tsx ================================================ "use client"; import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionForm, SettingsSectionGrid, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { StrategySelect } from "@app/components/StrategySelect"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { ListRolesResponse } from "@server/routers/role"; import { AxiosResponse } from "axios"; import { InfoIcon } from "lucide-react"; import { useTranslations } from "next-intl"; import Image from "next/image"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); const router = useRouter(); const [createLoading, setCreateLoading] = useState(false); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [roleMappingMode, setRoleMappingMode] = useState< "role" | "expression" >("role"); const t = useTranslations(); const { isPaidUser } = usePaidStatus(); const params = useParams(); const createIdpFormSchema = z.object({ name: z.string().min(2, { message: t("nameMin", { len: 2 }) }), type: z.enum(["oidc", "google", "azure"]), clientId: z.string().min(1, { message: t("idpClientIdRequired") }), clientSecret: z .string() .min(1, { message: t("idpClientSecretRequired") }), authUrl: z.url({ message: t("idpErrorAuthUrlInvalid") }).optional(), tokenUrl: z.url({ message: t("idpErrorTokenUrlInvalid") }).optional(), identifierPath: z .string() .min(1, { message: t("idpPathRequired") }) .optional(), emailPath: z.string().optional(), namePath: z.string().optional(), scopes: z .string() .min(1, { message: t("idpScopeRequired") }) .optional(), tenantId: z.string().optional(), autoProvision: z.boolean().default(false), roleMapping: z.string().nullable().optional(), roleId: z.number().nullable().optional() }); type CreateIdpFormValues = z.infer; interface ProviderTypeOption { id: "oidc" | "google" | "azure"; title: string; description: string; icon?: React.ReactNode; } const providerTypes: ReadonlyArray = [ { id: "oidc", title: "OAuth2/OIDC", description: t("idpOidcDescription") }, { id: "google", title: t("idpGoogleTitle"), description: t("idpGoogleDescription"), icon: ( {t("idpGoogleAlt")} ) }, { id: "azure", title: t("idpAzureTitle"), description: t("idpAzureDescription"), icon: ( {t("idpAzureAlt")} ) } ]; const form = useForm({ resolver: zodResolver(createIdpFormSchema), defaultValues: { name: "", type: "oidc", clientId: "", clientSecret: "", authUrl: "", tokenUrl: "", identifierPath: "sub", namePath: "name", emailPath: "email", scopes: "openid profile email", tenantId: "", autoProvision: false, roleMapping: null, roleId: null } }); // Fetch roles on component mount useEffect(() => { async function fetchRoles() { const res = await api .get< AxiosResponse >(`/org/${params.orgId}/roles`) .catch((e) => { console.error(e); toast({ variant: "destructive", title: t("accessRoleErrorFetch"), description: formatAxiosError( e, t("accessRoleErrorFetchDescription") ) }); }); if (res?.status === 200) { setRoles(res.data.data.roles); } } fetchRoles(); }, []); // Handle provider type changes and set defaults const handleProviderChange = (value: "oidc" | "google" | "azure") => { form.setValue("type", value); if (value === "google") { // Set Google defaults form.setValue( "authUrl", "https://accounts.google.com/o/oauth2/v2/auth" ); form.setValue("tokenUrl", "https://oauth2.googleapis.com/token"); form.setValue("identifierPath", "email"); form.setValue("emailPath", "email"); form.setValue("namePath", "name"); form.setValue("scopes", "openid profile email"); } else if (value === "azure") { // Set Azure Entra ID defaults (URLs will be constructed dynamically) form.setValue( "authUrl", "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize" ); form.setValue( "tokenUrl", "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token" ); form.setValue("identifierPath", "email"); form.setValue("emailPath", "email"); form.setValue("namePath", "name"); form.setValue("scopes", "openid profile email"); form.setValue("tenantId", ""); } else { // Reset to OIDC defaults form.setValue("authUrl", ""); form.setValue("tokenUrl", ""); form.setValue("identifierPath", "sub"); form.setValue("namePath", "name"); form.setValue("emailPath", "email"); form.setValue("scopes", "openid profile email"); } }; async function onSubmit(data: CreateIdpFormValues) { setCreateLoading(true); try { // Construct URLs dynamically for Azure provider let authUrl = data.authUrl; let tokenUrl = data.tokenUrl; if (data.type === "azure" && data.tenantId) { authUrl = authUrl?.replace("{{TENANT_ID}}", data.tenantId); tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId); } const roleName = roles.find((r) => r.roleId === data.roleId)?.name; const payload = { name: data.name, clientId: data.clientId, clientSecret: data.clientSecret, authUrl: authUrl, tokenUrl: tokenUrl, identifierPath: data.identifierPath, emailPath: data.emailPath, namePath: data.namePath, autoProvision: data.autoProvision, roleMapping: roleMappingMode === "role" ? `'${roleName}'` : data.roleMapping || "", scopes: data.scopes, variant: data.type }; // Use the appropriate endpoint based on provider type const endpoint = "oidc"; const res = await api.put( `/org/${params.orgId}/idp/${endpoint}`, payload ); if (res.status === 201) { toast({ title: t("success"), description: t("idpCreatedDescription") }); router.push( `/${params.orgId}/settings/idp/${res.data.data.idpId}` ); } } catch (e) { toast({ title: t("error"), description: formatAxiosError(e), variant: "destructive" }); } finally { setCreateLoading(false); } } return ( <>
{t("idpTitle")} {t("idpCreateSettingsDescription")}
{t("idpType")}
{ handleProviderChange( value as "oidc" | "google" | "azure" ); }} cols={3} />
( {t("name")} {t("idpDisplayName")} )} />
{/* Auto Provision Settings */} {t("idpAutoProvisionUsers")} {t("idpAutoProvisionUsersDescription")}
{ form.setValue( "autoProvision", checked ); }} roleMappingMode={roleMappingMode} onRoleMappingModeChange={(data) => { setRoleMappingMode(data); // Clear roleId and roleMapping when mode changes form.setValue("roleId", null); form.setValue("roleMapping", null); }} roles={roles} roleIdFieldName="roleId" roleMappingFieldName="roleMapping" />
{form.watch("type") === "google" && ( {t("idpGoogleConfigurationTitle")} {t("idpGoogleConfigurationDescription")}
( {t("idpClientId")} {t( "idpGoogleClientIdDescription" )} )} /> ( {t("idpClientSecret")} {t( "idpGoogleClientSecretDescription" )} )} />
)} {form.watch("type") === "azure" && ( {t("idpAzureConfigurationTitle")} {t("idpAzureConfigurationDescription")}
( {t("idpTenantIdLabel")} {t( "idpAzureTenantIdDescription" )} )} /> ( {t("idpClientId")} {t( "idpAzureClientIdDescription2" )} )} /> ( {t("idpClientSecret")} {t( "idpAzureClientSecretDescription2" )} )} />
)} {form.watch("type") === "oidc" && ( {t("idpOidcConfigure")} {t("idpOidcConfigureDescription")}
( {t("idpClientId")} {t( "idpClientIdDescription" )} )} /> ( {t("idpClientSecret")} {t( "idpClientSecretDescription" )} )} /> ( {t("idpAuthUrl")} {t( "idpAuthUrlDescription" )} )} /> ( {t("idpTokenUrl")} {t( "idpTokenUrlDescription" )} )} /> {t("idpOidcConfigureAlert")} {t("idpOidcConfigureAlertDescription")}
{t("idpToken")} {t("idpTokenDescription")}
( {t("idpJmespathLabel")} {t( "idpJmespathLabelDescription" )} )} /> ( {t( "idpJmespathEmailPathOptional" )} {t( "idpJmespathEmailPathOptionalDescription" )} )} /> ( {t( "idpJmespathNamePathOptional" )} {t( "idpJmespathNamePathOptionalDescription" )} )} /> ( {t( "idpOidcConfigureScopes" )} {t( "idpOidcConfigureScopesDescription" )} )} />
)}
); } ================================================ FILE: src/app/[orgId]/settings/(private)/idp/layout.tsx ================================================ interface LayoutProps { children: React.ReactNode; params: Promise<{}>; } export default async function Layout(props: LayoutProps) { return props.children; } ================================================ FILE: src/app/[orgId]/settings/(private)/idp/page.tsx ================================================ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import IdpTable, { IdpRow } from "@app/components/OrgIdpTable"; import { getTranslations } from "next-intl/server"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { IdpGlobalModeBanner } from "@app/components/IdpGlobalModeBanner"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; type OrgIdpPageProps = { params: Promise<{ orgId: string }>; }; export const dynamic = "force-dynamic"; export default async function OrgIdpPage(props: OrgIdpPageProps) { const params = await props.params; let idps: IdpRow[] = []; try { const res = await internal.get>( `/org/${params.orgId}/idp`, await authCookieHeader() ); idps = res.data.data.idps; } catch (e) { console.error(e); } const t = await getTranslations(); return ( <> ); } ================================================ FILE: src/app/[orgId]/settings/(private)/license/layout.tsx ================================================ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import { cache } from "react"; import { getTranslations } from "next-intl/server"; import { build } from "@server/build"; import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; type LicensesSettingsProps = { children: React.ReactNode; params: Promise<{ orgId: string }>; }; export default async function LicensesSetingsLayoutProps({ children, params }: LicensesSettingsProps) { const { orgId } = await params; if (build !== "saas") { redirect(`/${orgId}/settings`); } const getUser = cache(verifySession); const user = await getUser(); if (!user) { redirect(`/`); } let orgUser = null; try { const res = await getCachedOrgUser(orgId, user.userId); orgUser = res.data.data; } catch { redirect(`/${orgId}`); } let org = null; try { const res = await getCachedOrg(orgId); org = res.data.data; } catch { redirect(`/${orgId}`); } if (!org?.org?.isBillingOrg || !orgUser?.isOwner) { redirect(`/${orgId}`); } const t = await getTranslations(); return ( <> {children} ); } ================================================ FILE: src/app/[orgId]/settings/(private)/license/page.tsx ================================================ import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types"; import { AxiosResponse } from "axios"; type Props = { params: Promise<{ orgId: string }>; }; export const dynamic = "force-dynamic"; export default async function Page({ params }: Props) { const { orgId } = await params; let licenseKeys: ListGeneratedLicenseKeysResponse = []; try { const data = await internal.get< AxiosResponse >(`/org/${orgId}/license`, await authCookieHeader()); licenseKeys = data.data.data; } catch {} return ; } ================================================ FILE: src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx ================================================ "use client"; import { useState } from "react"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { Button } from "@app/components/ui/button"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { useParams, useRouter } from "next/navigation"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { PickRemoteExitNodeDefaultsResponse, QuickStartRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon } from "lucide-react"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; export default function CredentialsPage() { const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId } = useParams(); const router = useRouter(); const t = useTranslations(); const { remoteExitNode } = useRemoteExitNodeContext(); const { isPaidUser } = usePaidStatus(); const [modalOpen, setModalOpen] = useState(false); const [credentials, setCredentials] = useState(null); const [currentRemoteExitNodeId, setCurrentRemoteExitNodeId] = useState< string | null >(remoteExitNode.remoteExitNodeId); const [regeneratedSecret, setRegeneratedSecret] = useState( null ); const [showCredentialsAlert, setShowCredentialsAlert] = useState(false); const [shouldDisconnect, setShouldDisconnect] = useState(true); const handleConfirmRegenerate = async () => { try { const response = await api.get< AxiosResponse >(`/org/${orgId}/pick-remote-exit-node-defaults`); const data = response.data.data; setCredentials(data); const rekeyRes = await api.put< AxiosResponse >(`/re-key/${orgId}/regenerate-remote-exit-node-secret`, { remoteExitNodeId: remoteExitNode.remoteExitNodeId, secret: data.secret, disconnect: shouldDisconnect }); if (rekeyRes && rekeyRes.status === 200) { const rekeyData = rekeyRes.data.data; if (rekeyData && rekeyData.remoteExitNodeId) { setCurrentRemoteExitNodeId(rekeyData.remoteExitNodeId); setRegeneratedSecret(data.secret); setCredentials({ ...data, remoteExitNodeId: rekeyData.remoteExitNodeId }); setShowCredentialsAlert(true); } } toast({ title: t("credentialsSaved"), description: t("credentialsSavedDescription") }); } catch (error) { toast({ variant: "destructive", title: t("error") || "Error", description: formatAxiosError(error) || t("credentialsRegenerateError") || "Failed to regenerate credentials" }); } }; const getConfirmationString = () => { return ( remoteExitNode?.name || remoteExitNode?.remoteExitNodeId || "My remote exit node" ); }; const displayRemoteExitNodeId = currentRemoteExitNodeId || remoteExitNode?.remoteExitNodeId || null; const displaySecret = regeneratedSecret || null; return ( <> {t("credentials")} {t("remoteNodeCredentialsDescription")} {t("endpoint")} {t("remoteExitNodeId")} {displayRemoteExitNodeId ? ( ) : ( {"••••••••••••••••"} )} {t("remoteExitNodeSecretKey")} {displaySecret ? ( ) : ( {"••••••••••••••••••••••••••••••••"} )} {showCredentialsAlert && displaySecret && ( {t("credentialsSave") || "Save the Credentials"} {t("credentialsSaveDescription") || "You will only be able to see this once. Make sure to copy it to a secure place."} )} {!env.flags.disableEnterpriseFeatures && ( )} { setModalOpen(val); // Prevent modal from reopening during refresh if (!val) { setTimeout(() => { router.refresh(); }, 150); } }} dialog={
{shouldDisconnect ? ( <>

{t( "remoteExitNodeRegenerateAndDisconnectConfirmation" )}

{t( "remoteExitNodeRegenerateAndDisconnectWarning" )}

) : ( <>

{t( "remoteExitNodeRegenerateCredentialsConfirmation" )}

{t( "remoteExitNodeRegenerateCredentialsWarning" )}

)}
} buttonText={ shouldDisconnect ? t("remoteExitNodeRegenerateAndDisconnect") : t("regenerateCredentialsButton") } onConfirm={handleConfirmRegenerate} string={getConfirmationString()} title={t("regenerateCredentials")} warningText={t("cannotbeUndone")} /> ); } ================================================ FILE: src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx ================================================ import { internal } from "@app/lib/api"; import { GetRemoteExitNodeResponse } from "@server/routers/remoteExitNode/types"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import RemoteExitNodeProvider from "@app/providers/RemoteExitNodeProvider"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import ExitNodeInfoCard from "@app/components/ExitNodeInfoCard"; interface SettingsLayoutProps { children: React.ReactNode; params: Promise<{ remoteExitNodeId: string; orgId: string }>; } export default async function SettingsLayout(props: SettingsLayoutProps) { const params = await props.params; const { children } = props; let remoteExitNode = null; try { const res = await internal.get< AxiosResponse >( `/org/${params.orgId}/remote-exit-node/${params.remoteExitNodeId}`, await authCookieHeader() ); remoteExitNode = res.data.data; } catch { redirect(`/${params.orgId}/settings/remote-exit-nodes`); } const t = await getTranslations(); const navItems = [ { title: t("credentials"), href: "/{orgId}/settings/remote-exit-nodes/{remoteExitNodeId}/credentials" } ]; return ( <>
{children}
); } ================================================ FILE: src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/page.tsx ================================================ import { redirect } from "next/navigation"; export default async function RemoteExitNodePage(props: { params: Promise<{ orgId: string; remoteExitNodeId: string }>; }) { const params = await props.params; redirect( `/${params.orgId}/settings/remote-exit-nodes/${params.remoteExitNodeId}/credentials` ); } ================================================ FILE: src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx ================================================ "use client"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { z } from "zod"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@app/components/ui/input"; import { Button } from "@app/components/ui/button"; import CopyTextBox from "@app/components/CopyTextBox"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { QuickStartRemoteExitNodeResponse, PickRemoteExitNodeDefaultsResponse } from "@server/routers/remoteExitNode/types"; import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon } from "lucide-react"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { StrategySelect } from "@app/components/StrategySelect"; export default function CreateRemoteExitNodePage() { const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId } = useParams(); const router = useRouter(); const searchParams = useSearchParams(); const t = useTranslations(); const [isLoading, setIsLoading] = useState(false); const [defaults, setDefaults] = useState(null); const [createdNode, setCreatedNode] = useState(null); const [strategy, setStrategy] = useState<"adopt" | "generate">("adopt"); const createRemoteExitNodeFormSchema = z .object({ remoteExitNodeId: z.string().optional(), secret: z.string().optional() }) .refine( (data) => { if (strategy === "adopt") { return data.remoteExitNodeId && data.secret; } return true; }, { message: t("remoteExitNodeCreate.validation.adoptRequired"), path: ["remoteExitNodeId"] } ); type CreateRemoteExitNodeFormValues = z.infer< typeof createRemoteExitNodeFormSchema >; const form = useForm({ resolver: zodResolver(createRemoteExitNodeFormSchema), defaultValues: {} }); // Check for query parameters and prefill form useEffect(() => { const remoteExitNodeId = searchParams.get("remoteExitNodeId"); const remoteExitNodeSecret = searchParams.get("remoteExitNodeSecret"); if (remoteExitNodeId && remoteExitNodeSecret) { setStrategy("adopt"); form.setValue("remoteExitNodeId", remoteExitNodeId); form.setValue("secret", remoteExitNodeSecret); } }, []); useEffect(() => { const loadDefaults = async () => { try { const response = await api.get< AxiosResponse >(`/org/${orgId}/pick-remote-exit-node-defaults`); setDefaults(response.data.data); } catch (error) { toast({ title: t("error"), description: t( "remoteExitNodeCreate.errors.loadDefaultsFailed" ), variant: "destructive" }); } }; // Only load defaults when strategy is "generate" if (strategy === "generate") { loadDefaults(); } }, [strategy]); const onSubmit = async (data: CreateRemoteExitNodeFormValues) => { if (strategy === "generate" && !defaults) { toast({ title: t("error"), description: t("remoteExitNodeCreate.errors.defaultsNotLoaded"), variant: "destructive" }); return; } if (strategy === "adopt" && (!data.remoteExitNodeId || !data.secret)) { toast({ title: t("error"), description: t("remoteExitNodeCreate.validation.adoptRequired"), variant: "destructive" }); return; } setIsLoading(true); try { const response = await api.put< AxiosResponse >(`/org/${orgId}/remote-exit-node`, { remoteExitNodeId: strategy === "generate" ? defaults!.remoteExitNodeId : data.remoteExitNodeId!, secret: strategy === "generate" ? defaults!.secret : data.secret! }); setCreatedNode(response.data.data); router.push(`/${orgId}/settings/remote-exit-nodes`); } catch (error) { toast({ title: t("error"), description: formatAxiosError( error, t("remoteExitNodeCreate.errors.createFailed") ), variant: "destructive" }); } finally { setIsLoading(false); } }; return ( <>
{t("remoteExitNodeCreate.strategy.title")} {t("remoteExitNodeCreate.strategy.description")} { setStrategy(value); // Clear adopt fields when switching to generate if (value === "generate") { form.setValue("remoteExitNodeId", ""); form.setValue("secret", ""); } }} cols={2} /> {strategy === "adopt" && ( {t("remoteExitNodeCreate.adopt.title")} {t( "remoteExitNodeCreate.adopt.description" )}
( {t( "remoteExitNodeCreate.adopt.nodeIdLabel" )} {t( "remoteExitNodeCreate.adopt.nodeIdDescription" )} )} /> ( {t( "remoteExitNodeCreate.adopt.secretLabel" )} {t( "remoteExitNodeCreate.adopt.secretDescription" )} )} />
)} {strategy === "generate" && ( {t("remoteExitNodeCreate.generate.title")} {t( "remoteExitNodeCreate.generate.description" )} )}
); } ================================================ FILE: src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx ================================================ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; import { AxiosResponse } from "axios"; import ExitNodesTable, { RemoteExitNodeRow } from "@app/components/ExitNodesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; type RemoteExitNodesPageProps = { params: Promise<{ orgId: string }>; }; export const dynamic = "force-dynamic"; export default async function RemoteExitNodesPage( props: RemoteExitNodesPageProps ) { const params = await props.params; let remoteExitNodes: ListRemoteExitNodesResponse["remoteExitNodes"] = []; try { const res = await internal.get< AxiosResponse >(`/org/${params.orgId}/remote-exit-nodes`, await authCookieHeader()); remoteExitNodes = res.data.data.remoteExitNodes; } catch (e) {} const t = await getTranslations(); const remoteExitNodeRows: RemoteExitNodeRow[] = remoteExitNodes.map( (node) => { return { name: node.name, id: node.remoteExitNodeId, exitNodeId: node.exitNodeId, address: node.address?.split("/")[0] || "-", endpoint: node.endpoint || "-", online: node.online, type: node.type, dateCreated: node.dateCreated, version: node.version || undefined, orgId: params.orgId }; } ); return ( <> ); } ================================================ FILE: src/app/[orgId]/settings/access/invitations/page.tsx ================================================ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import InvitationsTable, { InvitationRow } from "../../../../../components/InvitationsTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; type InvitationsPageProps = { params: Promise<{ orgId: string }>; }; export const dynamic = "force-dynamic"; export default async function InvitationsPage(props: InvitationsPageProps) { const params = await props.params; const t = await getTranslations(); const getUser = cache(verifySession); const user = await getUser(); let invitations: { inviteId: string; email: string; expiresAt: string; roleId: number; roleName?: string; }[] = []; let hasInvitations = false; const res = await internal .get< AxiosResponse<{ invitations: typeof invitations; pagination: { total: number }; }> >(`/org/${params.orgId}/invitations`, await authCookieHeader()) .catch((e) => {}); if (res && res.status === 200) { invitations = res.data.data.invitations; hasInvitations = res.data.data.pagination.total > 0; } let org: GetOrgResponse | null = null; const getOrg = cache(async () => internal .get< AxiosResponse >(`/org/${params.orgId}`, await authCookieHeader()) .catch((e) => { console.error(e); }) ); const orgRes = await getOrg(); if (orgRes && orgRes.status === 200) { org = orgRes.data.data; } const invitationRows: InvitationRow[] = invitations.map((invite) => { return { id: invite.inviteId, email: invite.email, expiresAt: new Date(Number(invite.expiresAt)).toISOString(), role: invite.roleName || t("accessRoleUnknown"), roleId: invite.roleId }; }); return ( <> ); } ================================================ FILE: src/app/[orgId]/settings/access/layout.tsx ================================================ interface AccessLayoutProps { children: React.ReactNode; params: Promise<{ orgId: string; }>; } export default async function ResourceLayout(props: AccessLayoutProps) { const params = await props.params; const { children } = props; return <>{children}; } ================================================ FILE: src/app/[orgId]/settings/access/page.tsx ================================================ import { redirect } from "next/navigation"; type AccessPageProps = { params: Promise<{ orgId: string }>; }; export default async function AccessPage(props: AccessPageProps) { const params = await props.params; redirect(`/${params.orgId}/settings/access/users`); return <>; } ================================================ FILE: src/app/[orgId]/settings/access/roles/page.tsx ================================================ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import { GetOrgResponse } from "@server/routers/org"; import OrgProvider from "@app/providers/OrgProvider"; import { ListRolesResponse } from "@server/routers/role"; import RolesTable, { type RoleRow } from "@app/components/RolesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; type RolesPageProps = { params: Promise<{ orgId: string }>; }; export const dynamic = "force-dynamic"; export default async function RolesPage(props: RolesPageProps) { const params = await props.params; let roles: ListRolesResponse["roles"] = []; let hasInvitations = false; const res = await internal .get< AxiosResponse >(`/org/${params.orgId}/roles`, await authCookieHeader()) .catch((e) => {}); if (res && res.status === 200) { roles = res.data.data.roles; } const invitationsRes = await internal .get< AxiosResponse<{ pagination: { total: number }; }> >( `/org/${params.orgId}/invitations?limit=1&offset=0`, await authCookieHeader() ) .catch((e) => {}); if (invitationsRes && invitationsRes.status === 200) { hasInvitations = invitationsRes.data.data.pagination.total > 0; } let org: GetOrgResponse | null = null; const orgRes = await getCachedOrg(params.orgId); if (orgRes && orgRes.status === 200) { org = orgRes.data.data; } const roleRows: RoleRow[] = roles; const t = await getTranslations(); return ( <> ); } ================================================ FILE: src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx ================================================ "use client"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import { Checkbox } from "@app/components/ui/checkbox"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { ListRolesResponse } from "@server/routers/role"; import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { useParams } from "next/navigation"; import { Button } from "@app/components/ui/button"; import { SettingsContainer, SettingsSection, SettingsSectionHeader, SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, SettingsSectionForm, SettingsSectionFooter } from "@app/components/Settings"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; import { UserType } from "@server/types/UserTypes"; export default function AccessControlsPage() { const { orgUser: user } = userOrgUserContext(); const api = createApiClient(useEnvContext()); const { orgId } = useParams(); const [loading, setLoading] = useState(false); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const t = useTranslations(); const formSchema = z.object({ username: z.string(), roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }), autoProvisioned: z.boolean() }); const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { username: user.username!, roleId: user.roleId?.toString(), autoProvisioned: user.autoProvisioned || false } }); useEffect(() => { async function fetchRoles() { const res = await api .get>(`/org/${orgId}/roles`) .catch((e) => { console.error(e); toast({ variant: "destructive", title: t("accessRoleErrorFetch"), description: formatAxiosError( e, t("accessRoleErrorFetchDescription") ) }); }); if (res?.status === 200) { setRoles(res.data.data.roles); } } fetchRoles(); form.setValue("roleId", user.roleId.toString()); form.setValue("autoProvisioned", user.autoProvisioned || false); }, []); async function onSubmit(values: z.infer) { setLoading(true); try { // Execute both API calls simultaneously const [roleRes, userRes] = await Promise.all([ api.post>( `/role/${values.roleId}/add/${user.userId}` ), api.post(`/org/${orgId}/user/${user.userId}`, { autoProvisioned: values.autoProvisioned }) ]); if (roleRes.status === 200 && userRes.status === 200) { toast({ variant: "default", title: t("userSaved"), description: t("userSavedDescription") }); } } catch (e) { toast({ variant: "destructive", title: t("accessRoleErrorAdd"), description: formatAxiosError( e, t("accessRoleErrorAddDescription") ) }); } setLoading(false); } return ( {t("accessControls")} {t("accessControlsDescription")}
{/* IDP Type Display */} {user.type !== UserType.Internal && user.idpType && (
{t("idp")}:
)} ( {t("role")} )} /> {user.idpAutoProvision && ( (
{t("autoProvisioned")}

{t( "autoProvisionedDescription" )}

)} /> )}
); } ================================================ FILE: src/app/[orgId]/settings/access/users/[userId]/layout.tsx ================================================ import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import { GetOrgUserResponse } from "@server/routers/user"; import OrgUserProvider from "@app/providers/OrgUserProvider"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { cache } from "react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; interface UserLayoutProps { children: React.ReactNode; params: Promise<{ userId: string; orgId: string }>; } export default async function UserLayoutProps(props: UserLayoutProps) { const params = await props.params; const { children } = props; const t = await getTranslations(); let user = null; try { const getOrgUser = cache(async () => internal.get>( `/org/${params.orgId}/user/${params.userId}`, await authCookieHeader() ) ); const res = await getOrgUser(); user = res.data.data; } catch { redirect(`/${params.orgId}/settings/sites`); } const navItems = [ { title: t("accessControls"), href: "/{orgId}/settings/access/users/{userId}/access-controls" } ]; return ( <> {children} ); } ================================================ FILE: src/app/[orgId]/settings/access/users/[userId]/page.tsx ================================================ import { redirect } from "next/navigation"; export default async function UserPage(props: { params: Promise<{ orgId: string; userId: string }>; }) { const { orgId, userId } = await props.params; redirect(`/${orgId}/settings/access/users/${userId}/access-controls`); } ================================================ FILE: src/app/[orgId]/settings/access/users/create/page.tsx ================================================ "use client"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { StrategyOption, StrategySelect } from "@app/components/StrategySelect"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import CopyTextBox from "@app/components/CopyTextBox"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { ListRolesResponse } from "@server/routers/role"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { Checkbox } from "@app/components/ui/checkbox"; import { ListIdpsResponse } from "@server/routers/idp"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; import Image from "next/image"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; type UserType = "internal" | "oidc"; interface IdpOption { idpId: number; name: string; type: string; variant: string | null; } interface UserOption { id: string; title: string; description: string; disabled: boolean; icon?: React.ReactNode; idpId?: number; variant?: string | null; } export default function Page() { const { orgId } = useParams(); const router = useRouter(); const { env } = useEnvContext(); const api = createApiClient({ env }); const t = useTranslations(); const { hasSaasSubscription } = usePaidStatus(); const [selectedOption, setSelectedOption] = useState( "internal" ); const [inviteLink, setInviteLink] = useState(null); const [loading, setLoading] = useState(false); const [expiresInDays, setExpiresInDays] = useState(1); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const [idps, setIdps] = useState([]); const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); const [userOptions, setUserOptions] = useState([]); const [dataLoaded, setDataLoaded] = useState(false); const internalFormSchema = z.object({ email: z.email({ message: t("emailInvalid") }), validForHours: z .string() .min(1, { message: t("inviteValidityDuration") }), roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) }); const googleAzureFormSchema = z.object({ email: z.email({ message: t("emailInvalid") }), name: z.string().optional(), roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) }); const genericOidcFormSchema = z.object({ username: z.string().min(1, { message: t("usernameRequired") }), email: z .email({ message: t("emailInvalid") }) .optional() .or(z.literal("")), name: z.string().optional(), roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) }); const formatIdpType = (type: string) => { switch (type.toLowerCase()) { case "oidc": return t("idpGenericOidc"); case "google": return t("idpGoogleDescription"); case "azure": return t("idpAzureDescription"); default: return type; } }; const getIdpIcon = (variant: string | null) => { if (!variant) return null; switch (variant.toLowerCase()) { case "google": return ( {t("idpGoogleAlt")} ); case "azure": return ( {t("idpAzureAlt")} ); default: return null; } }; const validFor = [ { hours: 24, name: t("day", { count: 1 }) }, { hours: 48, name: t("day", { count: 2 }) }, { hours: 72, name: t("day", { count: 3 }) }, { hours: 96, name: t("day", { count: 4 }) }, { hours: 120, name: t("day", { count: 5 }) }, { hours: 144, name: t("day", { count: 6 }) }, { hours: 168, name: t("day", { count: 7 }) } ]; const internalForm = useForm({ resolver: zodResolver(internalFormSchema), defaultValues: { email: "", validForHours: "72", roleId: "" } }); const googleAzureForm = useForm({ resolver: zodResolver(googleAzureFormSchema), defaultValues: { email: "", name: "", roleId: "" } }); const genericOidcForm = useForm({ resolver: zodResolver(genericOidcFormSchema), defaultValues: { username: "", email: "", name: "", roleId: "" } }); useEffect(() => { if (selectedOption === "internal") { setSendEmail(env.email.emailEnabled); internalForm.reset(); setInviteLink(null); setExpiresInDays(1); } else if (selectedOption && selectedOption !== "internal") { googleAzureForm.reset(); genericOidcForm.reset(); } }, [ selectedOption, env.email.emailEnabled, internalForm, googleAzureForm, genericOidcForm ]); useEffect(() => { if (!selectedOption) { return; } async function fetchRoles() { const res = await api .get>(`/org/${orgId}/roles`) .catch((e) => { console.error(e); toast({ variant: "destructive", title: t("accessRoleErrorFetch"), description: formatAxiosError( e, t("accessRoleErrorFetchDescription") ) }); }); if (res?.status === 200) { setRoles(res.data.data.roles); } } async function fetchIdps() { if (build === "saas" && !hasSaasSubscription(tierMatrix.orgOidc)) { return; } const res = await api .get< AxiosResponse >(build === "saas" ? `/org/${orgId}/idp` : "/idp") .catch((e) => { console.error(e); toast({ variant: "destructive", title: t("idpErrorFetch"), description: formatAxiosError( e, t("idpErrorFetchDescription") ) }); }); if (res?.status === 200) { setIdps(res.data.data.idps); } } async function fetchInitialData() { setDataLoaded(false); await fetchRoles(); await fetchIdps(); setDataLoaded(true); } fetchInitialData(); }, []); // Build user options when IDPs are loaded useEffect(() => { const options: UserOption[] = [ { id: "internal", title: t("userTypeInternal"), description: t("userTypeInternalDescription"), disabled: false } ]; // Add IDP options idps.forEach((idp) => { options.push({ id: `idp-${idp.idpId}`, title: idp.name, description: formatIdpType(idp.variant || idp.type), disabled: false, icon: getIdpIcon(idp.variant), idpId: idp.idpId, variant: idp.variant }); }); setUserOptions(options); }, [idps, t]); async function onSubmitInternal( values: z.infer ) { setLoading(true); const res = await api .post>( `/org/${orgId}/create-invite`, { email: values.email, roleId: parseInt(values.roleId), validHours: parseInt(values.validForHours), sendEmail: sendEmail } as InviteUserBody ) .catch((e) => { if (e.response?.status === 409) { toast({ variant: "destructive", title: t("userErrorExists"), description: t("userErrorExistsDescription") }); } else { toast({ variant: "destructive", title: t("inviteError"), description: formatAxiosError( e, t("inviteErrorDescription") ) }); } }); if (res && res.status === 200) { setInviteLink(res.data.data.inviteLink); toast({ variant: "default", title: t("userInvited"), description: t("userInvitedDescription") }); setExpiresInDays(parseInt(values.validForHours) / 24); } setLoading(false); } async function onSubmitGoogleAzure( values: z.infer ) { const selectedUserOption = userOptions.find( (opt) => opt.id === selectedOption ); if (!selectedUserOption?.idpId) return; setLoading(true); const res = await api .put(`/org/${orgId}/user`, { username: values.email, // Use email as username for Google/Azure email: values.email || undefined, name: values.name, type: "oidc", idpId: selectedUserOption.idpId, roleId: parseInt(values.roleId) }) .catch((e) => { toast({ variant: "destructive", title: t("userErrorCreate"), description: formatAxiosError( e, t("userErrorCreateDescription") ) }); }); if (res && res.status === 201) { toast({ variant: "default", title: t("userCreated"), description: t("userCreatedDescription") }); router.push(`/${orgId}/settings/access/users`); } setLoading(false); } async function onSubmitGenericOidc( values: z.infer ) { const selectedUserOption = userOptions.find( (opt) => opt.id === selectedOption ); if (!selectedUserOption?.idpId) return; setLoading(true); const res = await api .put(`/org/${orgId}/user`, { username: values.username, email: values.email || undefined, name: values.name, type: "oidc", idpId: selectedUserOption.idpId, roleId: parseInt(values.roleId) }) .catch((e) => { toast({ variant: "destructive", title: t("userErrorCreate"), description: formatAxiosError( e, t("userErrorCreateDescription") ) }); }); if (res && res.status === 201) { toast({ variant: "default", title: t("userCreated"), description: t("userCreatedDescription") }); router.push(`/${orgId}/settings/access/users`); } setLoading(false); } return ( <>
{!inviteLink ? ( {t("userTypeTitle")} {t("userTypeDescription")} { setSelectedOption(value); if (value === "internal") { internalForm.reset(); } else { googleAzureForm.reset(); genericOidcForm.reset(); } }} cols={2} /> ) : null} {selectedOption === "internal" && dataLoaded && ( <> {!inviteLink ? ( {t("userSettings")} {t("userSettingsDescription")}
( {t("email")} )} /> ( {t( "inviteValid" )} )} /> ( {t("role")} )} /> {env.email.emailEnabled && (
setSendEmail( e as boolean ) } />
)}
) : ( {t("userInvited")} {sendEmail ? t( "inviteEmailSentDescription" ) : t("inviteSentDescription")}

{t("inviteExpiresIn", { days: expiresInDays })}

)} )} {selectedOption && selectedOption !== "internal" && dataLoaded && ( {t("userSettings")} {t("userSettingsDescription")} {/* Google/Azure Form */} {(() => { const selectedUserOption = userOptions.find( (opt) => opt.id === selectedOption ); return ( selectedUserOption?.variant === "google" || selectedUserOption?.variant === "azure" ); })() && (
( {t("email")} )} /> ( {t( "nameOptional" )} )} /> ( {t("role")} )} /> )} {/* Generic OIDC Form */} {(() => { const selectedUserOption = userOptions.find( (opt) => opt.id === selectedOption ); return ( selectedUserOption?.variant !== "google" && selectedUserOption?.variant !== "azure" ); })() && (
( {t( "username" )}

{t( "usernameUniq" )}

)} /> ( {t( "emailOptional" )} )} /> ( {t( "nameOptional" )} )} /> ( {t("role")} )} /> )}
)}
{selectedOption && dataLoaded && ( )}
); } ================================================ FILE: src/app/[orgId]/settings/access/users/page.tsx ================================================ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { ListUsersResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import UsersTable, { UserRow } from "../../../../../components/UsersTable"; import { GetOrgResponse } from "@server/routers/org"; import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import UserProvider from "@app/providers/UserProvider"; import { verifySession } from "@app/lib/auth/verifySession"; import AccessPageHeaderAndNav from "../../../../../components/AccessPageHeaderAndNav"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; type UsersPageProps = { params: Promise<{ orgId: string }>; }; export const dynamic = "force-dynamic"; export default async function UsersPage(props: UsersPageProps) { const params = await props.params; const getUser = cache(verifySession); const user = await getUser(); const t = await getTranslations(); let users: ListUsersResponse["users"] = []; let hasInvitations = false; const res = await internal .get< AxiosResponse >(`/org/${params.orgId}/users`, await authCookieHeader()) .catch((e) => {}); if (res && res.status === 200) { users = res.data.data.users; } const invitationsRes = await internal .get< AxiosResponse<{ pagination: { total: number }; }> >( `/org/${params.orgId}/invitations?limit=1&offset=0`, await authCookieHeader() ) .catch((e) => {}); if (invitationsRes && invitationsRes.status === 200) { hasInvitations = invitationsRes.data.data.pagination.total > 0; } let org: GetOrgResponse | null = null; const getOrg = cache(async () => internal .get< AxiosResponse >(`/org/${params.orgId}`, await authCookieHeader()) .catch((e) => { console.error(e); }) ); const orgRes = await getOrg(); if (orgRes && orgRes.status === 200) { org = orgRes.data.data; } const userRows: UserRow[] = users.map((user) => { return { id: user.id, username: user.username, displayUsername: getUserDisplayName({ email: user.email, name: user.name, username: user.username }), name: user.name, email: user.email, type: user.type, idpVariant: user.idpVariant, idpId: user.idpId, idpName: user.idpName || t("idpNameInternal"), status: t("userConfirmed"), role: user.isOwner ? t("accessRoleOwner") : user.roleName || t("accessRoleMember"), isOwner: user.isOwner || false }; }); return ( <> ); } ================================================ FILE: src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx ================================================ import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { GetApiKeyResponse } from "@server/routers/apiKeys"; import ApiKeyProvider from "@app/providers/ApiKeyProvider"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { getTranslations } from "next-intl/server"; interface SettingsLayoutProps { children: React.ReactNode; params: Promise<{ apiKeyId: string; orgId: string }>; } export default async function SettingsLayout(props: SettingsLayoutProps) { const params = await props.params; const t = await getTranslations(); const { children } = props; let apiKey = null; try { const res = await internal.get>( `/org/${params.orgId}/api-key/${params.apiKeyId}`, await authCookieHeader() ); apiKey = res.data.data; } catch (e) { console.log(e); redirect(`/${params.orgId}/settings/api-keys`); } const navItems = [ { title: t("apiKeysPermissionsTitle"), href: "/{orgId}/settings/api-keys/{apiKeyId}/permissions" } ]; return ( <> {children} ); } ================================================ FILE: src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx ================================================ import { redirect } from "next/navigation"; export default async function ApiKeysPage(props: { params: Promise<{ orgId: string; apiKeyId: string }>; }) { const params = await props.params; redirect( `/${params.orgId}/settings/api-keys/${params.apiKeyId}/permissions` ); } ================================================ FILE: src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx ================================================ "use client"; import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { Button } from "@app/components/ui/button"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { ListApiKeyActionsResponse } from "@server/routers/apiKeys"; import { AxiosResponse } from "axios"; import { useParams } from "next/navigation"; import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId, apiKeyId } = useParams(); const t = useTranslations(); const [loadingPage, setLoadingPage] = useState(true); const [selectedPermissions, setSelectedPermissions] = useState< Record >({}); const [loadingSavePermissions, setLoadingSavePermissions] = useState(false); useEffect(() => { async function load() { setLoadingPage(true); const res = await api .get< AxiosResponse >(`/org/${orgId}/api-key/${apiKeyId}/actions`) .catch((e) => { toast({ variant: "destructive", title: t("apiKeysPermissionsErrorLoadingActions"), description: formatAxiosError( e, t("apiKeysPermissionsErrorLoadingActions") ) }); }); if (res && res.status === 200) { const data = res.data.data; for (const action of data.actions) { setSelectedPermissions((prev) => ({ ...prev, [action.actionId]: true })); } } setLoadingPage(false); } load(); }, []); async function savePermissions() { setLoadingSavePermissions(true); const actionsRes = await api .post(`/org/${orgId}/api-key/${apiKeyId}/actions`, { actionIds: Object.keys(selectedPermissions).filter( (key) => selectedPermissions[key] ) }) .catch((e) => { console.error(t("apiKeysErrorSetPermission"), e); toast({ variant: "destructive", title: t("apiKeysErrorSetPermission"), description: formatAxiosError(e) }); }); if (actionsRes && actionsRes.status === 200) { toast({ title: t("apiKeysPermissionsUpdated"), description: t("apiKeysPermissionsUpdatedDescription") }); } setLoadingSavePermissions(false); } return ( <> {!loadingPage && ( {t("apiKeysPermissionsGeneralSettings")} {t( "apiKeysPermissionsGeneralSettingsDescription" )} )} ); } ================================================ FILE: src/app/[orgId]/settings/api-keys/create/page.tsx ================================================ "use client"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { z } from "zod"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@app/components/ui/input"; import { InfoIcon } from "lucide-react"; import { Button } from "@app/components/ui/button"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; import { useParams, useRouter } from "next/navigation"; import { CreateOrgApiKeyBody, CreateOrgApiKeyResponse } from "@server/routers/apiKeys"; import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; import CopyToClipboard from "@app/components/CopyToClipboard"; import moment from "moment"; import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox"; import CopyTextBox from "@app/components/CopyTextBox"; import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; import { useTranslations } from "next-intl"; export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId } = useParams(); const router = useRouter(); const t = useTranslations(); const [loadingPage, setLoadingPage] = useState(true); const [createLoading, setCreateLoading] = useState(false); const [apiKey, setApiKey] = useState(null); const [selectedPermissions, setSelectedPermissions] = useState< Record >({}); const createFormSchema = z.object({ name: z .string() .min(2, { message: t("nameMin", { len: 2 }) }) .max(255, { message: t("nameMax", { len: 255 }) }) }); type CreateFormValues = z.infer; const copiedFormSchema = z .object({ copied: z.boolean() }) .refine( (data) => { return data.copied; }, { message: t("apiKeysConfirmCopy2"), path: ["copied"] } ); type CopiedFormValues = z.infer; const form = useForm({ resolver: zodResolver(createFormSchema), defaultValues: { name: "" } }); const copiedForm = useForm({ resolver: zodResolver(copiedFormSchema), defaultValues: { copied: true } }); async function onSubmit(data: CreateFormValues) { setCreateLoading(true); const payload: CreateOrgApiKeyBody = { name: data.name }; const res = await api .put< AxiosResponse >(`/org/${orgId}/api-key/`, payload) .catch((e) => { toast({ variant: "destructive", title: t("apiKeysErrorCreate"), description: formatAxiosError(e) }); }); if (res && res.status === 201) { const data = res.data.data; console.log({ actionIds: Object.keys(selectedPermissions).filter( (key) => selectedPermissions[key] ) }); const actionsRes = await api .post(`/org/${orgId}/api-key/${data.apiKeyId}/actions`, { actionIds: Object.keys(selectedPermissions).filter( (key) => selectedPermissions[key] ) }) .catch((e) => { console.error(t("apiKeysErrorSetPermission"), e); toast({ variant: "destructive", title: t("apiKeysErrorSetPermission"), description: formatAxiosError(e) }); }); if (actionsRes) { setApiKey(data); } } setCreateLoading(false); } async function onCopiedSubmit(data: CopiedFormValues) { if (!data.copied) { return; } router.push(`/${orgId}/settings/api-keys`); } const formatLabel = (str: string) => { return str .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .replace(/^./, (char) => char.toUpperCase()); }; useEffect(() => { const load = async () => { setLoadingPage(false); }; load(); }, []); return ( <>
{!loadingPage && (
{!apiKey && ( <> {t("apiKeysTitle")}
{ if (e.key === "Enter") { e.preventDefault(); // block default enter refresh } }} className="space-y-4" id="create-site-form" > ( {t("name")} )} />
{t("apiKeysGeneralSettings")} {t( "apiKeysGeneralSettingsDescription" )} )} {apiKey && ( {t("apiKeysList")} {t("name")} {t("created")} {moment( apiKey.createdAt ).format("lll")} {t("apiKeysSave")} {t("apiKeysSaveDescription")} {/*

*/} {/* {t('apiKeysInfo')} */} {/*

*/} {/*
*/} {/* */} {/* ( */} {/* */} {/*
*/} {/* { */} {/* copiedForm.setValue( */} {/* "copied", */} {/* e as boolean */} {/* ); */} {/* }} */} {/* /> */} {/* */} {/*
*/} {/* */} {/*
*/} {/* )} */} {/* /> */} {/* */} {/* */}
)}
{!apiKey && ( )} {!apiKey && ( )} {apiKey && ( )}
)} ); } ================================================ FILE: src/app/[orgId]/settings/api-keys/page.tsx ================================================ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import OrgApiKeysTable, { OrgApiKeyRow } from "../../../../components/OrgApiKeysTable"; import { ListOrgApiKeysResponse } from "@server/routers/apiKeys"; import { getTranslations } from "next-intl/server"; type ApiKeyPageProps = { params: Promise<{ orgId: string }>; }; export const dynamic = "force-dynamic"; export default async function ApiKeysPage(props: ApiKeyPageProps) { const params = await props.params; const t = await getTranslations(); let apiKeys: ListOrgApiKeysResponse["apiKeys"] = []; try { const res = await internal.get>( `/org/${params.orgId}/api-keys`, await authCookieHeader() ); apiKeys = res.data.data.apiKeys; } catch (e) {} const rows: OrgApiKeyRow[] = apiKeys.map((key) => { return { name: key.name, id: key.apiKeyId, key: `${key.apiKeyId}••••••••••••••••••••${key.lastChars}`, createdAt: key.createdAt }; }); return ( <> ); } ================================================ FILE: src/app/[orgId]/settings/blueprints/[blueprintId]/page.tsx ================================================ import BlueprintDetailsForm from "@app/components/BlueprintDetailsForm"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import { GetBlueprintResponse } from "@server/routers/blueprints"; import { AxiosResponse } from "axios"; import { ArrowLeft } from "lucide-react"; import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { notFound, redirect } from "next/navigation"; type BluePrintsPageProps = { params: Promise<{ orgId: string; blueprintId: string }>; }; export const metadata: Metadata = { title: "Blueprint Detail" }; export default async function BluePrintDetailPage(props: BluePrintsPageProps) { const params = await props.params; let org = null; try { const res = await getCachedOrg(params.orgId); org = res.data.data; } catch { redirect(`/${params.orgId}`); } let blueprint = null; try { const res = await internal.get>( `/org/${params.orgId}/blueprint/${params.blueprintId}`, await authCookieHeader() ); blueprint = res.data.data; } catch (e) { console.error(e); notFound(); } const t = await getTranslations(); return ( <> ); } ================================================ FILE: src/app/[orgId]/settings/blueprints/create/page.tsx ================================================ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; import type { Metadata } from "next"; import { ArrowLeft } from "lucide-react"; import CreateBlueprintForm from "@app/components/CreateBlueprintForm"; export interface CreateBlueprintPageProps { params: Promise<{ orgId: string }>; } export const metadata: Metadata = { title: "Create blueprint" }; export default async function CreateBlueprintPage( props: CreateBlueprintPageProps ) { const t = await getTranslations(); const orgId = (await props.params).orgId; return ( <>
); } ================================================ FILE: src/app/[orgId]/settings/blueprints/page.tsx ================================================ import BlueprintsTable, { type BlueprintRow } from "@app/components/BlueprintsTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; import { ListBlueprintsResponse } from "@server/routers/blueprints"; import { AxiosResponse } from "axios"; import { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; type BluePrintsPageProps = { params: Promise<{ orgId: string }>; }; export const metadata: Metadata = { title: "Blueprints" }; export default async function BluePrintsPage(props: BluePrintsPageProps) { const params = await props.params; let blueprints: BlueprintRow[] = []; try { const res = await internal.get>( `/org/${params.orgId}/blueprints`, await authCookieHeader() ); blueprints = res.data.data.blueprints; } catch (e) { console.error(e); } let org = null; try { const res = await getCachedOrg(params.orgId); org = res.data.data; } catch { redirect(`/${params.orgId}`); } const t = await getTranslations(); return ( ); } ================================================ FILE: src/app/[orgId]/settings/clients/layout.tsx ================================================ import { redirect } from "next/navigation"; import { pullEnv } from "@app/lib/pullEnv"; export const dynamic = "force-dynamic"; interface SettingsLayoutProps { children: React.ReactNode; params: Promise<{ orgId: string }>; } export default async function SettingsLayout(props: SettingsLayoutProps) { const params = await props.params; const { children } = props; const env = pullEnv(); return children; } ================================================ FILE: src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx ================================================ "use client"; import { useState } from "react"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { Button } from "@app/components/ui/button"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { PickClientDefaultsResponse } from "@server/routers/client"; import { useClientContext } from "@app/hooks/useClientContext"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon } from "lucide-react"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { OlmInstallCommands } from "@app/components/olm-install-commands"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; export default function CredentialsPage() { const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId } = useParams(); const router = useRouter(); const t = useTranslations(); const { client } = useClientContext(); const [modalOpen, setModalOpen] = useState(false); const [clientDefaults, setClientDefaults] = useState(null); const [currentOlmId, setCurrentOlmId] = useState( client.olmId ); const [regeneratedSecret, setRegeneratedSecret] = useState( null ); const [showCredentialsAlert, setShowCredentialsAlert] = useState(false); const [shouldDisconnect, setShouldDisconnect] = useState(true); const { isPaidUser } = usePaidStatus(); const handleConfirmRegenerate = async () => { try { const res = await api.get(`/org/${orgId}/pick-client-defaults`); if (res && res.status === 200) { const data = res.data.data; const rekeyRes = await api.post( `/re-key/${client?.clientId}/regenerate-client-secret`, { secret: data.olmSecret, disconnect: shouldDisconnect } ); if (rekeyRes && rekeyRes.status === 200) { const rekeyData = rekeyRes.data.data; if (rekeyData && rekeyData.olmId) { setCurrentOlmId(rekeyData.olmId); setRegeneratedSecret(data.olmSecret); setClientDefaults({ ...data, olmId: rekeyData.olmId }); setShowCredentialsAlert(true); } } toast({ title: t("credentialsSaved"), description: t("credentialsSavedDescription") }); } } catch (error) { toast({ variant: "destructive", title: t("error") || "Error", description: formatAxiosError(error) || t("credentialsRegenerateError") || "Failed to regenerate credentials" }); } }; const getConfirmationString = () => { return client?.name || client?.clientId?.toString() || "My client"; }; const displayOlmId = currentOlmId || clientDefaults?.olmId || null; const displaySecret = regeneratedSecret || null; return ( <> {t("clientOlmCredentials")} {t("clientOlmCredentialsDescription")} {t("olmEndpoint")} {t("olmId")} {displayOlmId ? ( ) : ( {"••••••••••••••••"} )} {t("olmSecretKey")} {displaySecret ? ( ) : ( {"••••••••••••••••••••••••••••••••"} )} {showCredentialsAlert && displaySecret && ( {t("clientCredentialsSave")} {t("clientCredentialsSaveDescription")} )} {!env.flags.disableEnterpriseFeatures && ( )} { setModalOpen(val); // Prevent modal from reopening during refresh if (!val) { setTimeout(() => { router.refresh(); }, 150); } }} dialog={
{shouldDisconnect ? ( <>

{t( "clientRegenerateAndDisconnectConfirmation" )}

{t("clientRegenerateAndDisconnectWarning")}

) : ( <>

{t( "clientRegenerateCredentialsConfirmation" )}

{t("clientRegenerateCredentialsWarning")}

)}
} buttonText={ shouldDisconnect ? t("clientRegenerateAndDisconnect") : t("regenerateCredentialsButton") } onConfirm={handleConfirmRegenerate} string={getConfirmationString()} title={t("regenerateCredentials")} warningText={t("cannotbeUndone")} /> ); } ================================================ FILE: src/app/[orgId]/settings/clients/machine/[niceId]/general/page.tsx ================================================ "use client"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { useClientContext } from "@app/hooks/useClientContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { ListSitesResponse } from "@server/routers/site"; import { AxiosResponse } from "axios"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import ActionBanner from "@app/components/ActionBanner"; import { Shield, ShieldOff } from "lucide-react"; const GeneralFormSchema = z.object({ name: z.string().nonempty("Name is required"), niceId: z.string().min(1).max(255).optional() }); type GeneralFormValues = z.infer; export default function GeneralPage() { const t = useTranslations(); const { client, updateClient } = useClientContext(); const api = createApiClient(useEnvContext()); const [loading, setLoading] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const router = useRouter(); const [, startTransition] = useTransition(); const form = useForm({ resolver: zodResolver(GeneralFormSchema), defaultValues: { name: client?.name, niceId: client?.niceId || "" }, mode: "onChange" }); // Fetch available sites and client's assigned sites useEffect(() => { const fetchSites = async () => { try { // Fetch all available sites const res = await api.get>( `/org/${client?.orgId}/sites/` ); } catch (e) { toast({ variant: "destructive", title: "Failed to fetch sites", description: formatAxiosError( e, "An error occurred while fetching sites." ) }); } }; if (client?.clientId) { fetchSites(); } }, [client?.clientId, client?.orgId, api, form]); async function onSubmit(data: GeneralFormValues) { setLoading(true); try { await api.post(`/client/${client?.clientId}`, { name: data.name, niceId: data.niceId }); updateClient({ name: data.name, niceId: data.niceId }); toast({ title: t("clientUpdated"), description: t("clientUpdatedDescription") }); router.refresh(); } catch (e) { toast({ variant: "destructive", title: t("clientUpdateFailed"), description: formatAxiosError(e, t("clientUpdateError")) }); } finally { setLoading(false); } } const handleUnblock = async () => { if (!client?.clientId) return; setIsRefreshing(true); try { await api.post(`/client/${client.clientId}/unblock`); // Optimistically update the client context updateClient({ blocked: false, approvalState: null }); toast({ title: t("unblockClient"), description: t("unblockClientDescription") }); startTransition(() => { router.refresh(); }); } catch (e) { toast({ variant: "destructive", title: t("error"), description: formatAxiosError(e, t("error")) }); } finally { setIsRefreshing(false); } }; return ( {/* Blocked Device Banner */} {client?.blocked && ( } description={t("deviceBlockedDescription")} actions={ } /> )} {t("generalSettings")} {t("generalSettingsDescription")}
( {t("name")} )} /> ( {t("identifier")} )} />
); } ================================================ FILE: src/app/[orgId]/settings/clients/machine/[niceId]/layout.tsx ================================================ import ClientInfoCard from "@app/components/ClientInfoCard"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import ClientProvider from "@app/providers/ClientProvider"; import { GetClientResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; type SettingsLayoutProps = { children: React.ReactNode; params: Promise<{ niceId: number | string; orgId: string }>; }; export default async function SettingsLayout(props: SettingsLayoutProps) { const params = await props.params; const { children } = props; let client = null; try { console.log( "making request to ", `/org/${params.orgId}/client/${params.niceId}` ); const res = await internal.get>( `/org/${params.orgId}/client/${params.niceId}`, await authCookieHeader() ); client = res.data.data; } catch (error) { console.error("Error fetching client data:", error); redirect(`/${params.orgId}/settings/clients`); } const t = await getTranslations(); const navItems = [ { title: t("general"), href: `/{orgId}/settings/clients/machine/{niceId}/general` }, { title: t("credentials"), href: `/{orgId}/settings/clients/machine/{niceId}/credentials` } ]; return ( <>
{children}
); } ================================================ FILE: src/app/[orgId]/settings/clients/machine/[niceId]/page.tsx ================================================ import { redirect } from "next/navigation"; export default async function ClientPage(props: { params: Promise<{ orgId: string; niceId: number | string }>; }) { const params = await props.params; redirect( `/${params.orgId}/settings/clients/machine/${params.niceId}/general` ); } ================================================ FILE: src/app/[orgId]/settings/clients/machine/create/page.tsx ================================================ "use client"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import HeaderTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { CreateClientBody, CreateClientResponse, PickClientDefaultsResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { ChevronDown, ChevronUp } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { OlmInstallCommands } from "@app/components/olm-install-commands"; import { useTranslations } from "next-intl"; type ClientType = "olm"; interface TunnelTypeOption { id: ClientType; title: string; description: string; disabled?: boolean; } export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); const { orgId } = useParams(); const router = useRouter(); const t = useTranslations(); const createClientFormSchema = z.object({ name: z .string() .min(2, { message: t("nameMin", { len: 2 }) }) .max(30, { message: t("nameMax", { len: 30 }) }), method: z.enum(["olm"]), subnet: z.union([z.ipv4(), z.ipv6()]).refine((val) => val.length > 0, { message: t("subnetRequired") }) }); type CreateClientFormValues = z.infer; const [tunnelTypes, setTunnelTypes] = useState< ReadonlyArray >([ { id: "olm", title: t("olmTunnel"), description: t("olmTunnelDescription"), disabled: true } ]); const [loadingPage, setLoadingPage] = useState(true); const [olmId, setOlmId] = useState(""); const [olmSecret, setOlmSecret] = useState(""); const [olmVersion, setOlmVersion] = useState("latest"); const [createLoading, setCreateLoading] = useState(false); const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [clientDefaults, setClientDefaults] = useState(null); const form = useForm({ resolver: zodResolver(createClientFormSchema), defaultValues: { name: "", method: "olm", subnet: "" } }); async function onSubmit(data: CreateClientFormValues) { setCreateLoading(true); if (!clientDefaults) { toast({ variant: "destructive", title: t("errorCreatingClient"), description: t("clientDefaultsNotFound") }); setCreateLoading(false); return; } const payload: CreateClientBody = { name: data.name, type: data.method as "olm", olmId: clientDefaults.olmId, secret: clientDefaults.olmSecret, subnet: data.subnet }; const res = await api .put< AxiosResponse >(`/org/${orgId}/client`, payload) .catch((e) => { toast({ variant: "destructive", title: t("errorCreatingClient"), description: formatAxiosError(e) }); }); if (res && res.status === 201) { const data = res.data.data; router.push(`/${orgId}/settings/clients/machine/${data.niceId}`); } setCreateLoading(false); } useEffect(() => { const load = async () => { setLoadingPage(true); try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); const response = await fetch( `https://api.github.com/repos/fosrl/olm/releases/latest`, { signal: controller.signal } ); clearTimeout(timeoutId); if (!response.ok) { throw new Error( t("olmErrorFetchReleases", { err: response.statusText }) ); } const data = await response.json(); const latestVersion = data.tag_name; setOlmVersion(latestVersion); } catch (error) { if (error instanceof Error && error.name === "AbortError") { console.error(t("olmErrorFetchTimeout")); } else { console.error( t("olmErrorFetchLatest", { err: error instanceof Error ? error.message : String(error) }) ); } } await api .get(`/org/${orgId}/pick-client-defaults`) .catch((e) => { form.setValue("method", "olm"); }) .then((res) => { if (res && res.status === 200) { const data = res.data.data; setClientDefaults(data); const olmId = data.olmId; const olmSecret = data.olmSecret; setOlmId(olmId); setOlmSecret(olmSecret); if (data.subnet) { form.setValue("subnet", data.subnet); } setTunnelTypes((prev: any) => { return prev.map((item: any) => { return { ...item, disabled: false }; }); }); } }); setLoadingPage(false); }; load(); }, []); return ( <>
{!loadingPage && (
{t("clientInformation")}
{ if (e.key === "Enter") { e.preventDefault(); // block default enter refresh } }} className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start" id="create-client-form" > ( {t("name")} {t( "clientNameDescription" )} )} />
{showAdvancedSettings && ( ( {t("clientAddress")} {t( "addressDescription" )} )} /> )}
{form.watch("method") === "olm" && ( <> {t("clientOlmCredentials")} {t( "clientOlmCredentialsDescription" )} {t("olmEndpoint")} {t("olmId")} {t("olmSecretKey")} )}
)} ); } ================================================ FILE: src/app/[orgId]/settings/clients/machine/page.tsx ================================================ import type { ClientRow } from "@app/components/MachineClientsTable"; import MachineClientsTable from "@app/components/MachineClientsTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import MachineClientsBanner from "@app/components/MachineClientsBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListClientsResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import type { Pagination } from "@server/types/Pagination"; type ClientsPageProps = { params: Promise<{ orgId: string }>; searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; const searchParams = new URLSearchParams(await props.searchParams); let machineClients: ListClientsResponse["clients"] = []; let pagination: Pagination = { page: 1, total: 0, pageSize: 20 }; try { const machineRes = await internal.get< AxiosResponse >( `/org/${params.orgId}/clients?${searchParams.toString()}`, await authCookieHeader() ); const responseData = machineRes.data.data; machineClients = responseData.clients; pagination = responseData.pagination; } catch (e) {} function formatSize(mb: number): string { if (mb >= 1024 * 1024) { return `${(mb / (1024 * 1024)).toFixed(2)} TB`; } else if (mb >= 1024) { return `${(mb / 1024).toFixed(2)} GB`; } else { return `${mb.toFixed(2)} MB`; } } const mapClientToRow = ( client: ListClientsResponse["clients"][0] ): ClientRow => { return { name: client.name, id: client.clientId, subnet: client.subnet.split("/")[0], mbIn: formatSize(client.megabytesIn || 0), mbOut: formatSize(client.megabytesOut || 0), orgId: params.orgId, online: client.online, olmVersion: client.olmVersion || undefined, olmUpdateAvailable: client.olmUpdateAvailable || false, userId: client.userId, username: client.username, userEmail: client.userEmail, niceId: client.niceId, agent: client.agent, archived: client.archived || false, blocked: client.blocked || false, approvalState: client.approvalState ?? "approved" }; }; const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow); return ( <> ); } ================================================ FILE: src/app/[orgId]/settings/clients/page.tsx ================================================ import { redirect } from "next/navigation"; type ClientsPageProps = { params: Promise<{ orgId: string }>; searchParams: Promise<{ view?: string }>; }; export const dynamic = "force-dynamic"; export default async function ClientsPage(props: ClientsPageProps) { const params = await props.params; redirect(`/${params.orgId}/settings/clients/user`); } ================================================ FILE: src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx ================================================ "use client"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { useClientContext } from "@app/hooks/useClientContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; import { InfoSection, InfoSectionContent, InfoSections, InfoSectionTitle } from "@app/components/InfoSection"; import { Badge } from "@app/components/ui/badge"; import { Button } from "@app/components/ui/button"; import ActionBanner from "@app/components/ActionBanner"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { toast } from "@app/hooks/useToast"; import { useRouter } from "next/navigation"; import { useState, useEffect, useTransition } from "react"; import { Check, Ban, Shield, ShieldOff, Clock, CheckCircle2, XCircle } from "lucide-react"; import { useParams } from "next/navigation"; import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; import { SiAndroid } from "react-icons/si"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; function formatTimestamp(timestamp: number | null | undefined): string { if (!timestamp) return "-"; return new Date(timestamp * 1000).toLocaleString(); } function formatPlatform(platform: string | null | undefined): string { if (!platform) return "-"; const platformMap: Record = { macos: "macOS", windows: "Windows", linux: "Linux", ios: "iOS", android: "Android", unknown: "Unknown" }; return platformMap[platform.toLowerCase()] || platform; } function getPlatformIcon(platform: string | null | undefined) { if (!platform) return null; const normalizedPlatform = platform.toLowerCase(); switch (normalizedPlatform) { case "macos": case "ios": return ; case "windows": return ; case "linux": return ; case "android": return ; default: return null; } } type FieldConfig = { show: boolean; labelKey: string; }; function getPlatformFieldConfig( platform: string | null | undefined ): Record { const normalizedPlatform = platform?.toLowerCase() || "unknown"; const configs: Record> = { macos: { osVersion: { show: true, labelKey: "macosVersion" }, kernelVersion: { show: false, labelKey: "kernelVersion" }, arch: { show: true, labelKey: "architecture" }, deviceModel: { show: true, labelKey: "deviceModel" }, serialNumber: { show: true, labelKey: "serialNumber" }, username: { show: true, labelKey: "username" }, hostname: { show: true, labelKey: "hostname" } }, windows: { osVersion: { show: true, labelKey: "windowsVersion" }, kernelVersion: { show: true, labelKey: "kernelVersion" }, arch: { show: true, labelKey: "architecture" }, deviceModel: { show: true, labelKey: "deviceModel" }, serialNumber: { show: true, labelKey: "serialNumber" }, username: { show: true, labelKey: "username" }, hostname: { show: true, labelKey: "hostname" } }, linux: { osVersion: { show: true, labelKey: "osVersion" }, kernelVersion: { show: true, labelKey: "kernelVersion" }, arch: { show: true, labelKey: "architecture" }, deviceModel: { show: true, labelKey: "deviceModel" }, serialNumber: { show: true, labelKey: "serialNumber" }, username: { show: true, labelKey: "username" }, hostname: { show: true, labelKey: "hostname" } }, ios: { osVersion: { show: true, labelKey: "iosVersion" }, kernelVersion: { show: false, labelKey: "kernelVersion" }, arch: { show: true, labelKey: "architecture" }, deviceModel: { show: true, labelKey: "deviceModel" } }, android: { osVersion: { show: true, labelKey: "androidVersion" }, kernelVersion: { show: true, labelKey: "kernelVersion" }, arch: { show: true, labelKey: "architecture" }, deviceModel: { show: true, labelKey: "deviceModel" } }, unknown: { osVersion: { show: true, labelKey: "osVersion" }, kernelVersion: { show: true, labelKey: "kernelVersion" }, arch: { show: true, labelKey: "architecture" }, deviceModel: { show: true, labelKey: "deviceModel" }, serialNumber: { show: true, labelKey: "serialNumber" }, username: { show: true, labelKey: "username" }, hostname: { show: true, labelKey: "hostname" } } }; return configs[normalizedPlatform] || configs.unknown; } export default function GeneralPage() { const { client, updateClient } = useClientContext(); const { isPaidUser } = usePaidStatus(); const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); const params = useParams(); const orgId = params.orgId as string; const [approvalId, setApprovalId] = useState(null); const [isRefreshing, setIsRefreshing] = useState(false); const [, startTransition] = useTransition(); const { env } = useEnvContext(); const showApprovalFeatures = build !== "oss" && isPaidUser(tierMatrix.deviceApprovals); const formatPostureValue = (value: boolean | null | undefined | "-") => { if (value === null || value === undefined || value === "-") return "-"; return (
{value ? ( ) : ( )} {value ? t("enabled") : t("disabled")}
); }; // Fetch approval ID for this client if pending useEffect(() => { if ( showApprovalFeatures && client.approvalState === "pending" && client.clientId ) { api.get(`/org/${orgId}/approvals?approvalState=pending`) .then((res) => { const approval = res.data.data.approvals.find( (a: any) => a.clientId === client.clientId ); if (approval) { setApprovalId(approval.approvalId); } }) .catch(() => { // Silently fail - approval might not exist }); } }, [ showApprovalFeatures, client.approvalState, client.clientId, orgId, api ]); const handleApprove = async () => { if (!approvalId) return; setIsRefreshing(true); try { await api.put(`/org/${orgId}/approvals/${approvalId}`, { decision: "approved" }); // Optimistically update the client context updateClient({ approvalState: "approved" }); toast({ title: t("accessApprovalUpdated"), description: t("accessApprovalApprovedDescription") }); startTransition(() => { router.refresh(); }); } catch (e) { toast({ variant: "destructive", title: t("accessApprovalErrorUpdate"), description: formatAxiosError( e, t("accessApprovalErrorUpdateDescription") ) }); } finally { setIsRefreshing(false); } }; const handleDeny = async () => { if (!approvalId) return; setIsRefreshing(true); try { await api.put(`/org/${orgId}/approvals/${approvalId}`, { decision: "denied" }); // Optimistically update the client context updateClient({ approvalState: "denied", blocked: true }); toast({ title: t("accessApprovalUpdated"), description: t("accessApprovalDeniedDescription") }); startTransition(() => { router.refresh(); }); } catch (e) { toast({ variant: "destructive", title: t("accessApprovalErrorUpdate"), description: formatAxiosError( e, t("accessApprovalErrorUpdateDescription") ) }); } finally { setIsRefreshing(false); } }; const handleBlock = async () => { if (!client.clientId) return; setIsRefreshing(true); try { await api.post(`/client/${client.clientId}/block`); // Optimistically update the client context updateClient({ blocked: true, approvalState: "denied" }); toast({ title: t("blockClient"), description: t("blockClientMessage") }); startTransition(() => { router.refresh(); }); } catch (e) { toast({ variant: "destructive", title: t("error"), description: formatAxiosError(e, t("error")) }); } finally { setIsRefreshing(false); } }; const handleUnblock = async () => { if (!client.clientId) return; setIsRefreshing(true); try { await api.post(`/client/${client.clientId}/unblock`); // Optimistically update the client context updateClient({ blocked: false, approvalState: null }); toast({ title: t("unblockClient"), description: t("unblockClientDescription") }); startTransition(() => { router.refresh(); }); } catch (e) { toast({ variant: "destructive", title: t("error"), description: formatAxiosError(e, t("error")) }); } finally { setIsRefreshing(false); } }; return ( {/* Pending Approval Banner */} {showApprovalFeatures && client.approvalState === "pending" && ( } description={t("devicePendingApprovalBannerDescription")} actions={ <> } /> )} {/* Blocked Device Banner */} {client.blocked && client.approvalState !== "pending" && ( } description={t("deviceBlockedDescription")} actions={ } /> )} {/* Device Information Section */} {(client.fingerprint || (client.agent && client.olmVersion)) && ( {t("deviceInformation")} {t("deviceInformationDescription")} {client.agent && client.olmVersion && (
{t("agent")} {client.agent + " v" + client.olmVersion}
)} {client.fingerprint && (() => { const platform = client.fingerprint.platform; const fieldConfig = getPlatformFieldConfig(platform); return ( {platform && ( {t("platform")}
{getPlatformIcon( platform )} {formatPlatform( platform )}
)} {client.fingerprint.osVersion && fieldConfig.osVersion?.show && ( {t( fieldConfig .osVersion ?.labelKey || "osVersion" )} { client.fingerprint .osVersion } )} {client.fingerprint.kernelVersion && fieldConfig.kernelVersion?.show && ( {t("kernelVersion")} { client.fingerprint .kernelVersion } )} {client.fingerprint.arch && fieldConfig.arch.show && ( {t("architecture")} { client.fingerprint .arch } )} {client.fingerprint.deviceModel && fieldConfig.deviceModel?.show && ( {t("deviceModel")} { client.fingerprint .deviceModel } )} {client.fingerprint.serialNumber && fieldConfig.serialNumber.show && ( {t("serialNumber")} { client.fingerprint .serialNumber } )} {client.fingerprint.username && fieldConfig.username?.show && ( {t("username")} { client.fingerprint .username } )} {client.fingerprint.hostname && fieldConfig.hostname?.show && ( {t("hostname")} { client.fingerprint .hostname } )} {client.fingerprint.firstSeen && ( {t("firstSeen")} {formatTimestamp( client.fingerprint .firstSeen )} )} {client.fingerprint.lastSeen && ( {t("lastSeen")} {formatTimestamp( client.fingerprint .lastSeen )} )}
); })()}
)} {!env.flags.disableEnterpriseFeatures && ( {t("deviceSecurity")} {t("deviceSecurityDescription")} {client.posture && Object.keys(client.posture).length > 0 ? ( <> {client.posture.biometricsEnabled !== null && client.posture.biometricsEnabled !== undefined && ( {t("biometricsEnabled")} {isPaidUser( tierMatrix.devicePosture ) ? formatPostureValue( client.posture .biometricsEnabled === true ) : "-"} )} {client.posture.diskEncrypted !== null && client.posture.diskEncrypted !== undefined && ( {t("diskEncrypted")} {isPaidUser( tierMatrix.devicePosture ) ? formatPostureValue( client.posture .diskEncrypted === true ) : "-"} )} {client.posture.firewallEnabled !== null && client.posture.firewallEnabled !== undefined && ( {t("firewallEnabled")} {isPaidUser( tierMatrix.devicePosture ) ? formatPostureValue( client.posture .firewallEnabled === true ) : "-"} )} {client.posture.autoUpdatesEnabled !== null && client.posture.autoUpdatesEnabled !== undefined && ( {t("autoUpdatesEnabled")} {isPaidUser( tierMatrix.devicePosture ) ? formatPostureValue( client.posture .autoUpdatesEnabled === true ) : "-"} )} {client.posture.tpmAvailable !== null && client.posture.tpmAvailable !== undefined && ( {t("tpmAvailable")} {isPaidUser( tierMatrix.devicePosture ) ? formatPostureValue( client.posture .tpmAvailable === true ) : "-"} )} {client.posture.windowsAntivirusEnabled !== null && client.posture .windowsAntivirusEnabled !== undefined && ( {t( "windowsAntivirusEnabled" )} {isPaidUser( tierMatrix.devicePosture ) ? formatPostureValue( client.posture .windowsAntivirusEnabled === true ) : "-"} )} {client.posture.macosSipEnabled !== null && client.posture.macosSipEnabled !== undefined && ( {t("macosSipEnabled")} {isPaidUser( tierMatrix.devicePosture ) ? formatPostureValue( client.posture .macosSipEnabled === true ) : "-"} )} {client.posture.macosGatekeeperEnabled !== null && client.posture .macosGatekeeperEnabled !== undefined && ( {t( "macosGatekeeperEnabled" )} {isPaidUser( tierMatrix.devicePosture ) ? formatPostureValue( client.posture .macosGatekeeperEnabled === true ) : "-"} )} {client.posture.macosFirewallStealthMode !== null && client.posture .macosFirewallStealthMode !== undefined && ( {t( "macosFirewallStealthMode" )} {isPaidUser( tierMatrix.devicePosture ) ? formatPostureValue( client.posture .macosFirewallStealthMode === true ) : "-"} )} {client.posture.linuxAppArmorEnabled !== null && client.posture.linuxAppArmorEnabled !== undefined && ( {t("linuxAppArmorEnabled")} {isPaidUser( tierMatrix.devicePosture ) ? formatPostureValue( client.posture .linuxAppArmorEnabled === true ) : "-"} )} {client.posture.linuxSELinuxEnabled !== null && client.posture.linuxSELinuxEnabled !== undefined && ( {t("linuxSELinuxEnabled")} {isPaidUser( tierMatrix.devicePosture ) ? formatPostureValue( client.posture .linuxSELinuxEnabled === true ) : "-"} )} ) : (
{t("noData")}
)}
)}
); } ================================================ FILE: src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx ================================================ import ClientInfoCard from "@app/components/ClientInfoCard"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import ClientProvider from "@app/providers/ClientProvider"; import { GetClientResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; type SettingsLayoutProps = { children: React.ReactNode; params: Promise<{ niceId: number | string; orgId: string }>; }; export default async function SettingsLayout(props: SettingsLayoutProps) { const params = await props.params; const { children } = props; let client = null; try { const res = await internal.get>( `/org/${params.orgId}/client/${params.niceId}`, await authCookieHeader() ); client = res.data.data; } catch (error) { redirect(`/${params.orgId}/settings/clients/user`); } const t = await getTranslations(); const navItems = [ { title: t("general"), href: `/${params.orgId}/settings/clients/user/${params.niceId}/general` } ]; return ( <>
{children}
); } ================================================ FILE: src/app/[orgId]/settings/clients/user/[niceId]/page.tsx ================================================ import { redirect } from "next/navigation"; export default async function ClientPage(props: { params: Promise<{ orgId: string; niceId: number | string }>; }) { const params = await props.params; redirect( `/${params.orgId}/settings/clients/user/${params.niceId}/general` ); } ================================================ FILE: src/app/[orgId]/settings/clients/user/page.tsx ================================================ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import type { ClientRow } from "@app/components/UserDevicesTable"; import UserDevicesTable from "@app/components/UserDevicesTable"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { type ListUserDevicesResponse } from "@server/routers/client"; import type { Pagination } from "@server/types/Pagination"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; type ClientsPageProps = { params: Promise<{ orgId: string }>; searchParams: Promise>; }; export const dynamic = "force-dynamic"; export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; const searchParams = new URLSearchParams(await props.searchParams); let userClients: ListUserDevicesResponse["devices"] = []; let pagination: Pagination = { page: 1, total: 0, pageSize: 20 }; try { const userRes = await internal.get< AxiosResponse >( `/org/${params.orgId}/user-devices?${searchParams.toString()}`, await authCookieHeader() ); const responseData = userRes.data.data; userClients = responseData.devices; pagination = responseData.pagination; } catch (e) {} function formatSize(mb: number): string { if (mb >= 1024 * 1024) { return `${(mb / (1024 * 1024)).toFixed(2)} TB`; } else if (mb >= 1024) { return `${(mb / 1024).toFixed(2)} GB`; } else { return `${mb.toFixed(2)} MB`; } } const mapClientToRow = ( client: ListUserDevicesResponse["devices"][number] ): ClientRow => { // Build fingerprint object if any fingerprint data exists const hasFingerprintData = client.fingerprintPlatform || client.fingerprintOsVersion || client.fingerprintKernelVersion || client.fingerprintArch || client.fingerprintSerialNumber || client.fingerprintUsername || client.fingerprintHostname || client.deviceModel; const fingerprint = hasFingerprintData ? { platform: client.fingerprintPlatform, osVersion: client.fingerprintOsVersion, kernelVersion: client.fingerprintKernelVersion, arch: client.fingerprintArch, deviceModel: client.deviceModel, serialNumber: client.fingerprintSerialNumber, username: client.fingerprintUsername, hostname: client.fingerprintHostname } : null; return { name: client.name, id: client.clientId, subnet: client.subnet.split("/")[0], mbIn: formatSize(client.megabytesIn ?? 0), mbOut: formatSize(client.megabytesOut ?? 0), orgId: params.orgId, online: client.online, olmVersion: client.olmVersion || undefined, olmUpdateAvailable: Boolean(client.olmUpdateAvailable), userId: client.userId, username: client.username, userEmail: client.userEmail, niceId: client.niceId, agent: client.agent, archived: Boolean(client.archived), blocked: Boolean(client.blocked), approvalState: client.approvalState, fingerprint }; }; const userClientRows: ClientRow[] = userClients.map(mapClientToRow); return ( <> ); } ================================================ FILE: src/app/[orgId]/settings/domains/[domainId]/page.tsx ================================================ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import DomainInfoCard from "@app/components/DomainInfoCard"; import RestartDomainButton from "@app/components/RestartDomainButton"; import { GetDomainResponse } from "@server/routers/domain/getDomain"; import { pullEnv } from "@app/lib/pullEnv"; import { getTranslations } from "next-intl/server"; import RefreshButton from "@app/components/RefreshButton"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { GetDNSRecordsResponse } from "@server/routers/domain"; import DNSRecordsTable from "@app/components/DNSRecordTable"; import DomainCertForm from "@app/components/DomainCertForm"; interface DomainSettingsPageProps { params: Promise<{ domainId: string; orgId: string }>; } export default async function DomainSettingsPage({ params }: DomainSettingsPageProps) { const { domainId, orgId } = await params; const t = await getTranslations(); const env = pullEnv(); let domain: GetDomainResponse | null = null; try { const res = await internal.get( `/org/${orgId}/domain/${domainId}`, await authCookieHeader() ); domain = res.data.data; } catch { return null; } let dnsRecords; try { const response = await internal.get( `/org/${orgId}/domain/${domainId}/dns-records`, await authCookieHeader() ); dnsRecords = response.data.data; } catch (error) { return null; } if (!domain) { return null; } return ( <>
{env.flags.usePangolinDns && domain.failed ? ( ) : ( )}
{domain.type == "wildcard" && !domain.configManaged && ( )}
); } ================================================ FILE: src/app/[orgId]/settings/domains/page.tsx ================================================ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import DomainsTable, { DomainRow } from "../../../../components/DomainsTable"; import { getTranslations } from "next-intl/server"; import { cache } from "react"; import { GetOrgResponse } from "@server/routers/org"; import { redirect } from "next/navigation"; import OrgProvider from "@app/providers/OrgProvider"; import { ListDomainsResponse } from "@server/routers/domain"; import { toUnicode } from "punycode"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; type Props = { params: Promise<{ orgId: string }>; }; export default async function DomainsPage(props: Props) { const params = await props.params; let domains: DomainRow[] = []; try { const res = await internal.get>( `/org/${params.orgId}/domains`, await authCookieHeader() ); const rawDomains = res.data.data.domains as DomainRow[]; domains = rawDomains.map((domain) => ({ ...domain, baseDomain: toUnicode(domain.baseDomain) })); } catch (e) { console.error(e); } let org = null; try { const res = await getCachedOrg(params.orgId); org = res.data.data; } catch { redirect(`/${params.orgId}`); } const t = await getTranslations(); return ( <> ); } ================================================ FILE: src/app/[orgId]/settings/general/auth-page/page.tsx ================================================ import AuthPageBrandingForm from "@app/components/AuthPageBrandingForm"; import AuthPageSettings from "@app/components/AuthPageSettings"; import { SettingsContainer } from "@app/components/Settings"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedSubscription } from "@app/lib/api/getCachedSubscription"; import { build } from "@server/build"; import type { GetOrgTierResponse } from "@server/routers/billing/types"; import { GetLoginPageBrandingResponse, GetLoginPageResponse } from "@server/routers/loginPage/types"; import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; export interface AuthPageProps { params: Promise<{ orgId: string }>; } export default async function AuthPage(props: AuthPageProps) { const orgId = (await props.params).orgId; let subscriptionStatus: GetOrgTierResponse | null = null; try { const subRes = await getCachedSubscription(orgId); subscriptionStatus = subRes.data.data; } catch {} let loginPage: GetLoginPageResponse | null = null; try { if (build === "saas") { const res = await internal.get>( `/org/${orgId}/login-page`, await authCookieHeader() ); if (res.status === 200) { loginPage = res.data.data; } } } catch (error) {} let loginPageBranding: GetLoginPageBrandingResponse | null = null; try { const res = await internal.get< AxiosResponse >(`/org/${orgId}/login-page-branding`, await authCookieHeader()); if (res.status === 200) { loginPageBranding = res.data.data; } } catch (error) {} return ( {build === "saas" && } ); } ================================================ FILE: src/app/[orgId]/settings/general/layout.tsx ================================================ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { HorizontalTabs, type TabItem } from "@app/components/HorizontalTabs"; import { verifySession } from "@app/lib/auth/verifySession"; import OrgProvider from "@app/providers/OrgProvider"; import OrgUserProvider from "@app/providers/OrgUserProvider"; import OrgInfoCard from "@app/components/OrgInfoCard"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; import { build } from "@server/build"; import { pullEnv } from "@app/lib/pullEnv"; type GeneralSettingsProps = { children: React.ReactNode; params: Promise<{ orgId: string }>; }; export default async function GeneralSettingsPage({ children, params }: GeneralSettingsProps) { const { orgId } = await params; const user = await verifySession(); const env = pullEnv(); if (!user) { redirect(`/`); } let orgUser = null; try { const res = await getCachedOrgUser(orgId, user.userId); orgUser = res.data.data; } catch { redirect(`/${orgId}`); } let org = null; try { const res = await getCachedOrg(orgId); org = res.data.data; } catch { redirect(`/${orgId}`); } const t = await getTranslations(); const navItems: TabItem[] = [ { title: t("general"), href: `/{orgId}/settings/general`, exact: true }, { title: t("security"), href: `/{orgId}/settings/general/security` }, // PaidFeaturesAlert ...(!env.flags.disableEnterpriseFeatures ? [ { title: t("authPage"), href: `/{orgId}/settings/general/auth-page` } ] : []) ]; return ( <>
{children}
); } ================================================ FILE: src/app/[orgId]/settings/general/page.tsx ================================================ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; import { useState, useTransition, useActionState } from "react"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { formatAxiosError } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; import { useRouter } from "next/navigation"; import { SettingsContainer, SettingsSection, SettingsSectionHeader, SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, SettingsSectionForm, SettingsSectionFooter } from "@app/components/Settings"; import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; import type { OrgContextType } from "@app/contexts/orgContext"; // Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), subnet: z.string().optional() }); export default function GeneralPage() { const { org } = useOrgContext(); return ( {!org.org.isBillingOrg && } ); } type SectionFormProps = { org: OrgContextType["org"]["org"]; }; function DeleteForm({ org }: SectionFormProps) { const t = useTranslations(); const api = createApiClient(useEnvContext()); const router = useRouter(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [loadingDelete, startTransition] = useTransition(); const { user } = useUserContext(); async function pickNewOrgAndNavigate() { try { const res = await api.get>( `/user/${user.userId}/orgs` ); if (res.status === 200) { if (res.data.data.orgs.length > 0) { const orgId = res.data.data.orgs[0].orgId; // go to `/${orgId}/settings`); router.push(`/${orgId}/settings`); } else { // go to `/setup` router.push("/setup"); } } } catch (err) { console.error(err); toast({ variant: "destructive", title: t("orgErrorFetch"), description: formatAxiosError(err, t("orgErrorFetchMessage")) }); } } async function deleteOrg() { try { const res = await api.delete>( `/org/${org.orgId}` ); toast({ title: t("orgDeleted"), description: t("orgDeletedMessage") }); if (res.status === 200) { pickNewOrgAndNavigate(); } } catch (err) { console.error(err); toast({ variant: "destructive", title: t("orgErrorDelete"), description: formatAxiosError(err, t("orgErrorDeleteMessage")) }); } } return ( <> { setIsDeleteModalOpen(val); }} dialog={

{t("orgQuestionRemove")}

{t("orgMessageRemove")}

} buttonText={t("orgDeleteConfirm")} onConfirm={async () => startTransition(deleteOrg)} string={org.name || ""} title={t("orgDelete")} /> {t("dangerSection")} {t("dangerSectionDescription")} ); } function GeneralSectionForm({ org }: SectionFormProps) { const { updateOrg } = useOrgContext(); const form = useForm({ resolver: zodResolver( GeneralFormSchema.pick({ name: true, subnet: true }) ), defaultValues: { name: org.name, subnet: org.subnet || "" // Add default value for subnet }, mode: "onChange" }); const t = useTranslations(); const router = useRouter(); const [, formAction, loadingSave] = useActionState(performSave, null); const api = createApiClient(useEnvContext()); async function performSave() { const isValid = await form.trigger(); if (!isValid) return; const data = form.getValues(); try { const reqData = { name: data.name } as any; // Update organization await api.post(`/org/${org.orgId}`, reqData); // Update the org context to reflect the change in the info card updateOrg({ name: data.name }); toast({ title: t("orgUpdated"), description: t("orgUpdatedDescription") }); router.refresh(); } catch (e) { toast({ variant: "destructive", title: t("orgErrorUpdate"), description: formatAxiosError(e, t("orgErrorUpdateMessage")) }); } } return ( {t("general")} {t("orgGeneralSettingsDescription")}
( {t("name")} {t("orgDisplayName")} )} />
); } ================================================ FILE: src/app/[orgId]/settings/general/security/page.tsx ================================================ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { toast } from "@app/hooks/useToast"; import { useState, useRef, useActionState, type ComponentRef } from "react"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { z } from "zod"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { formatAxiosError } from "@app/lib/api"; import { useRouter } from "next/navigation"; import { SettingsContainer, SettingsSection, SettingsSectionHeader, SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, SettingsSectionForm } from "@app/components/Settings"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; import { SwitchInput } from "@app/components/SwitchInput"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import type { OrgContextType } from "@app/contexts/orgContext"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; // Session length options in hours const SESSION_LENGTH_OPTIONS = [ { value: null, labelKey: "unenforced" }, { value: 1, labelKey: "1Hour" }, { value: 3, labelKey: "3Hours" }, { value: 6, labelKey: "6Hours" }, { value: 12, labelKey: "12Hours" }, { value: 24, labelKey: "1DaySession" }, { value: 72, labelKey: "3Days" }, { value: 168, labelKey: "7Days" }, { value: 336, labelKey: "14Days" }, { value: 720, labelKey: "30DaysSession" }, { value: 2160, labelKey: "90DaysSession" }, { value: 4320, labelKey: "180DaysSession" } ]; // Password expiry options in days - will be translated in component const PASSWORD_EXPIRY_OPTIONS = [ { value: null, labelKey: "neverExpire" }, { value: 1, labelKey: "1Day" }, { value: 30, labelKey: "30Days" }, { value: 60, labelKey: "60Days" }, { value: 90, labelKey: "90Days" }, { value: 180, labelKey: "180Days" }, { value: 365, labelKey: "1Year" } ]; // Schema for security organization settings const SecurityFormSchema = z.object({ requireTwoFactor: z.boolean().optional(), maxSessionLengthHours: z.number().nullable().optional(), passwordExpiryDays: z.number().nullable().optional(), settingsLogRetentionDaysRequest: z.number(), settingsLogRetentionDaysAccess: z.number(), settingsLogRetentionDaysAction: z.number() }); const LOG_RETENTION_OPTIONS = [ { label: "logRetentionDisabled", value: 0 }, { label: "logRetention3Days", value: 3 }, { label: "logRetention7Days", value: 7 }, { label: "logRetention14Days", value: 14 }, { label: "logRetention30Days", value: 30 }, { label: "logRetention90Days", value: 90 }, ...(build != "saas" ? [ { label: "logRetentionForever", value: -1 }, { label: "logRetentionEndOfFollowingYear", value: 9001 } ] : []) ]; type SectionFormProps = { org: OrgContextType["org"]["org"]; }; export default function SecurityPage() { const { org } = useOrgContext(); const { env } = useEnvContext(); return ( {!env.flags.disableEnterpriseFeatures && ( )} ); } function LogRetentionSectionForm({ org }: SectionFormProps) { const form = useForm({ resolver: zodResolver( SecurityFormSchema.pick({ settingsLogRetentionDaysRequest: true, settingsLogRetentionDaysAccess: true, settingsLogRetentionDaysAction: true }) ), defaultValues: { settingsLogRetentionDaysRequest: org.settingsLogRetentionDaysRequest ?? 15, settingsLogRetentionDaysAccess: org.settingsLogRetentionDaysAccess ?? 15, settingsLogRetentionDaysAction: org.settingsLogRetentionDaysAction ?? 15 }, mode: "onChange" }); const router = useRouter(); const t = useTranslations(); const { isPaidUser, subscriptionTier } = usePaidStatus(); const [, formAction, loadingSave] = useActionState(performSave, null); const { env } = useEnvContext(); const api = createApiClient({ env }); async function performSave() { const isValid = await form.trigger(); if (!isValid) return; const data = form.getValues(); try { const reqData = { settingsLogRetentionDaysRequest: data.settingsLogRetentionDaysRequest, settingsLogRetentionDaysAccess: data.settingsLogRetentionDaysAccess, settingsLogRetentionDaysAction: data.settingsLogRetentionDaysAction } as any; // Update organization await api.post(`/org/${org.orgId}`, reqData); toast({ title: t("orgUpdated"), description: t("orgUpdatedDescription") }); router.refresh(); } catch (e) { toast({ variant: "destructive", title: t("orgErrorUpdate"), description: formatAxiosError(e, t("orgErrorUpdateMessage")) }); } } return ( {t("logRetention")} {t("logRetentionDescription")}
( {t("logRetentionRequestLabel")} )} /> {!env.flags.disableEnterpriseFeatures && ( <> { const isDisabled = !isPaidUser( tierMatrix.accessLogs ); return ( {t( "logRetentionAccessLabel" )} ); }} /> { const isDisabled = !isPaidUser( tierMatrix.actionLogs ); return ( {t( "logRetentionActionLabel" )} ); }} /> )}
); } function SecuritySettingsSectionForm({ org }: SectionFormProps) { const router = useRouter(); const form = useForm({ resolver: zodResolver( SecurityFormSchema.pick({ requireTwoFactor: true, maxSessionLengthHours: true, passwordExpiryDays: true }) ), defaultValues: { requireTwoFactor: org.requireTwoFactor || false, maxSessionLengthHours: org.maxSessionLengthHours || null, passwordExpiryDays: org.passwordExpiryDays || null }, mode: "onChange" }); const t = useTranslations(); const { isPaidUser } = usePaidStatus(); // Track initial security policy values const initialSecurityValues = { requireTwoFactor: org.requireTwoFactor || false, maxSessionLengthHours: org.maxSessionLengthHours || null, passwordExpiryDays: org.passwordExpiryDays || null }; const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = useState(false); // Check if security policies have changed const hasSecurityPolicyChanged = () => { const currentValues = form.getValues(); return ( currentValues.requireTwoFactor !== initialSecurityValues.requireTwoFactor || currentValues.maxSessionLengthHours !== initialSecurityValues.maxSessionLengthHours || currentValues.passwordExpiryDays !== initialSecurityValues.passwordExpiryDays ); }; const [, formAction, loadingSave] = useActionState(onSubmit, null); const api = createApiClient(useEnvContext()); const formRef = useRef>(null); async function onSubmit() { // Check if security policies have changed if (hasSecurityPolicyChanged()) { setIsSecurityPolicyConfirmOpen(true); return; } await performSave(); } async function performSave() { const isValid = await form.trigger(); if (!isValid) return; const data = form.getValues(); try { const reqData = { requireTwoFactor: data.requireTwoFactor || false, maxSessionLengthHours: data.maxSessionLengthHours, passwordExpiryDays: data.passwordExpiryDays } as any; // Update organization await api.post(`/org/${org.orgId}`, reqData); toast({ title: t("orgUpdated"), description: t("orgUpdatedDescription") }); router.refresh(); } catch (e) { toast({ variant: "destructive", title: t("orgErrorUpdate"), description: formatAxiosError(e, t("orgErrorUpdateMessage")) }); } } return ( <>

{t("securityPolicyChangeDescription")}

} buttonText={t("saveSettings")} onConfirm={performSave} string={t("securityPolicyChangeConfirmMessage")} title={t("securityPolicyChangeWarning")} warningText={t("securityPolicyChangeWarningText")} /> {t("securitySettings")} {t("securitySettingsDescription")}
{ const isDisabled = !isPaidUser( tierMatrix.twoFactorEnforcement ); return (
{ if ( !isDisabled ) { form.setValue( "requireTwoFactor", val ); } }} />
{t( "requireTwoFactorDescription" )}
); }} /> { const isDisabled = !isPaidUser( tierMatrix.sessionDurationPolicies ); return ( {t("maxSessionLength")} {t( "maxSessionLengthDescription" )} ); }} /> { const isDisabled = !isPaidUser( tierMatrix.passwordExpirationPolicies ); return ( {t("passwordExpiryDays")} {t( "editPasswordExpiryDescription" )} ); }} />
); } ================================================ FILE: src/app/[orgId]/settings/layout.tsx ================================================ import { Metadata } from "next"; import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import { internal } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { ListUserOrgsResponse } from "@server/routers/org"; import { authCookieHeader } from "@app/lib/api/cookies"; import { cache } from "react"; import { GetOrgUserResponse } from "@server/routers/user"; import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; import { getTranslations } from "next-intl/server"; import { pullEnv } from "@app/lib/pullEnv"; import { orgNavSections } from "@app/app/navigation"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: { template: `%s - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, default: `Settings - ${process.env.BRANDING_APP_NAME || "Pangolin"}` }, description: "" }; interface SettingsLayoutProps { children: React.ReactNode; params: Promise<{ orgId: string }>; } export default async function SettingsLayout(props: SettingsLayoutProps) { const params = await props.params; const { children } = props; const getUser = cache(verifySession); const user = await getUser(); const env = pullEnv(); if (!user) { redirect(`/`); } const cookie = await authCookieHeader(); const t = await getTranslations(); try { const getOrgUser = cache(() => internal.get>( `/org/${params.orgId}/user/${user.userId}`, cookie ) ); const orgUser = await getOrgUser(); if (!orgUser.data.data.isAdmin && !orgUser.data.data.isOwner) { throw new Error(t("userErrorNotAdminOrOwner")); } } catch { redirect(`/${params.orgId}`); } let orgs: ListUserOrgsResponse["orgs"] = []; try { const getOrgs = cache(() => internal.get>( `/user/${user.userId}/orgs`, cookie ) ); const res = await getOrgs(); if (res && res.data.data.orgs) { orgs = res.data.data.orgs; } } catch (e) {} const primaryOrg = orgs.find((o) => o.orgId === params.orgId)?.isPrimaryOrg; return ( {children} ); } ================================================ FILE: src/app/[orgId]/settings/logs/access/page.tsx ================================================ "use client"; import { Button } from "@app/components/ui/button"; import { toast } from "@app/hooks/useToast"; import { useState, useRef, useEffect, useTransition } from "react"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { LogDataTable } from "@app/components/LogDataTable"; import { ColumnDef } from "@tanstack/react-table"; import { DateTimeValue } from "@app/components/DateTimePicker"; import { ArrowUpRight, Key, User } from "lucide-react"; import Link from "next/link"; import { ColumnFilter } from "@app/components/ColumnFilter"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { build } from "@server/build"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import axios from "axios"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; export default function GeneralPage() { const router = useRouter(); const searchParams = useSearchParams(); const api = createApiClient(useEnvContext()); const t = useTranslations(); const { orgId } = useParams(); const { isPaidUser } = usePaidStatus(); const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); const [isExporting, startTransition] = useTransition(); const [filterAttributes, setFilterAttributes] = useState<{ actors: string[]; resources: { id: number; name: string | null; }[]; locations: string[]; }>({ actors: [], resources: [], locations: [] }); // Filter states - unified object for all filters const [filters, setFilters] = useState<{ action?: string; type?: string; resourceId?: string; location?: string; actor?: string; }>({ action: searchParams.get("action") || undefined, type: searchParams.get("type") || undefined, resourceId: searchParams.get("resourceId") || undefined, location: searchParams.get("location") || undefined, actor: searchParams.get("actor") || undefined }); // Pagination state const [totalCount, setTotalCount] = useState(0); const [currentPage, setCurrentPage] = useState(0); const [isLoading, setIsLoading] = useState(false); // Initialize page size from storage or default const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20); // Set default date range to last 24 hours const getDefaultDateRange = () => { // if the time is in the url params, use that instead const startParam = searchParams.get("start"); const endParam = searchParams.get("end"); if (startParam && endParam) { return { startDate: { date: new Date(startParam) }, endDate: { date: new Date(endParam) } }; } const now = new Date(); const lastWeek = getSevenDaysAgo(); return { startDate: { date: lastWeek }, endDate: { date: now } }; }; const [dateRange, setDateRange] = useState<{ startDate: DateTimeValue; endDate: DateTimeValue; }>(getDefaultDateRange()); // Trigger search with default values on component mount useEffect(() => { const defaultRange = getDefaultDateRange(); queryDateTime( defaultRange.startDate, defaultRange.endDate, 0, pageSize ); }, [orgId]); // Re-run if orgId changes const handleDateRangeChange = ( startDate: DateTimeValue, endDate: DateTimeValue ) => { setDateRange({ startDate, endDate }); setCurrentPage(0); // Reset to first page when filtering // put the search params in the url for the time updateUrlParamsForAllFilters({ start: startDate.date?.toISOString() || "", end: endDate.date?.toISOString() || "" }); queryDateTime(startDate, endDate, 0, pageSize); }; // Handle page changes const handlePageChange = (newPage: number) => { setCurrentPage(newPage); queryDateTime( dateRange.startDate, dateRange.endDate, newPage, pageSize ); }; // Handle page size changes const handlePageSizeChange = (newPageSize: number) => { setPageSize(newPageSize); setCurrentPage(0); // Reset to first page when changing page size queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); }; // Handle filter changes generically const handleFilterChange = ( filterType: keyof typeof filters, value: string | undefined ) => { // Create new filters object with updated value const newFilters = { ...filters, [filterType]: value }; setFilters(newFilters); setCurrentPage(0); // Reset to first page when filtering // Update URL params updateUrlParamsForAllFilters(newFilters); // Trigger new query with updated filters (pass directly to avoid async state issues) queryDateTime( dateRange.startDate, dateRange.endDate, 0, pageSize, newFilters ); }; const updateUrlParamsForAllFilters = ( newFilters: | typeof filters | { start: string; end: string; } ) => { const params = new URLSearchParams(searchParams); Object.entries(newFilters).forEach(([key, value]) => { if (value) { params.set(key, value); } else { params.delete(key); } }); router.replace(`?${params.toString()}`, { scroll: false }); }; const queryDateTime = async ( startDate: DateTimeValue, endDate: DateTimeValue, page: number = currentPage, size: number = pageSize, filtersParam?: { action?: string; type?: string; resourceId?: string; location?: string; actor?: string; } ) => { console.log("Date range changed:", { startDate, endDate, page, size }); if (!isPaidUser(tierMatrix.accessLogs) || build === "oss") { console.log( "Access denied: subscription inactive or license locked" ); return; } setIsLoading(true); try { // Use the provided filters or fall back to current state const activeFilters = filtersParam || filters; // Convert the date/time values to API parameters const params: any = { limit: size, offset: page * size, ...activeFilters }; if (startDate?.date) { const startDateTime = new Date(startDate.date); if (startDate.time) { const [hours, minutes, seconds] = startDate.time .split(":") .map(Number); startDateTime.setHours(hours, minutes, seconds || 0); } params.timeStart = startDateTime.toISOString(); } if (endDate?.date) { const endDateTime = new Date(endDate.date); if (endDate.time) { const [hours, minutes, seconds] = endDate.time .split(":") .map(Number); endDateTime.setHours(hours, minutes, seconds || 0); } else { // If no time is specified, set to NOW const now = new Date(); endDateTime.setHours( now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds() ); } params.timeEnd = endDateTime.toISOString(); } const res = await api.get(`/org/${orgId}/logs/access`, { params }); if (res.status === 200) { setRows(res.data.data.log || []); setTotalCount(res.data.data.pagination?.total || 0); setFilterAttributes(res.data.data.filterAttributes); console.log("Fetched logs:", res.data); } } catch (error) { toast({ title: t("error"), description: t("Failed to filter logs"), variant: "destructive" }); } finally { setIsLoading(false); } }; const refreshData = async () => { console.log("Data refreshed"); setIsRefreshing(true); try { // Refresh data with current date range and pagination await queryDateTime( dateRange.startDate, dateRange.endDate, currentPage, pageSize ); } catch (error) { toast({ title: t("error"), description: t("refreshError"), variant: "destructive" }); } finally { setIsRefreshing(false); } }; const exportData = async () => { try { // Prepare query params for export const params: any = { timeStart: dateRange.startDate?.date ? new Date(dateRange.startDate.date).toISOString() : undefined, timeEnd: dateRange.endDate?.date ? new Date(dateRange.endDate.date).toISOString() : undefined, ...filters }; const response = await api.get(`/org/${orgId}/logs/access/export`, { responseType: "blob", params }); // Create a URL for the blob and trigger a download const url = window.URL.createObjectURL(new Blob([response.data])); const link = document.createElement("a"); link.href = url; const epoch = Math.floor(Date.now() / 1000); link.setAttribute( "download", `access-audit-logs-${orgId}-${epoch}.csv` ); document.body.appendChild(link); link.click(); link.parentNode?.removeChild(link); } catch (error) { let apiErrorMessage: string | null = null; if (axios.isAxiosError(error) && error.response) { const data = error.response.data; if (data instanceof Blob && data.type === "application/json") { // Parse the Blob as JSON const text = await data.text(); const errorData = JSON.parse(text); apiErrorMessage = errorData.message; } } toast({ title: t("error"), description: apiErrorMessage ?? t("exportError"), variant: "destructive" }); } }; const columns: ColumnDef[] = [ { accessorKey: "timestamp", header: ({ column }) => { return t("timestamp"); }, cell: ({ row }) => { return (
{new Date( row.original.timestamp * 1000 ).toLocaleString()}
); } }, { accessorKey: "action", header: ({ column }) => { return (
{t("action")} handleFilterChange("action", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( {row.original.action ? <>Allowed : <>Denied} ); } }, { accessorKey: "ip", header: ({ column }) => { return t("ip"); } }, { accessorKey: "location", header: ({ column }) => { return (
{t("location")} ({ value: location, label: location }) )} selectedValue={filters.location} onValueChange={(value) => handleFilterChange("location", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( {row.original.location ? ( {row.original.location} ) : ( - )} ); } }, { accessorKey: "resourceName", header: ({ column }) => { return (
{t("resource")} ({ value: res.id.toString(), label: res.name || "Unnamed Resource" }))} selectedValue={filters.resourceId} onValueChange={(value) => handleFilterChange("resourceId", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( ); } }, { accessorKey: "type", header: ({ column }) => { return (
{t("type")} handleFilterChange("type", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { // should be capitalized first letter return ( {row.original.type.charAt(0).toUpperCase() + row.original.type.slice(1) || "-"} ); } }, { accessorKey: "actor", header: ({ column }) => { return (
{t("actor")} ({ value: actor, label: actor }))} selectedValue={filters.actor} onValueChange={(value) => handleFilterChange("actor", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( {row.original.actor ? ( <> {row.original.actorType == "user" ? ( ) : ( )} {row.original.actor} ) : ( <>- )} ); } }, { accessorKey: "actorId", header: ({ column }) => { return t("actorId"); }, cell: ({ row }) => { return ( {row.original.actorId || "-"} ); } } ]; const renderExpandedRow = (row: any) => { return (
{row.userAgent != "node" && (
User Agent:

{row.userAgent || "N/A"}

)}
Metadata:
                            {row.metadata
                                ? JSON.stringify(
                                      JSON.parse(row.metadata),
                                      null,
                                      2
                                  )
                                : "N/A"}
                        
); }; return ( <> startTransition(exportData)} isExporting={isExporting} // isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan // !isPaidUser(tierMatrix.accessLogs) || build === "oss" // } onDateRangeChange={handleDateRangeChange} dateRange={{ start: dateRange.startDate, end: dateRange.endDate }} defaultSort={{ id: "timestamp", desc: true }} // Server-side pagination props totalCount={totalCount} currentPage={currentPage} pageSize={pageSize} onPageChange={handlePageChange} onPageSizeChange={handlePageSizeChange} isLoading={isLoading} // Row expansion props expandable={true} renderExpandedRow={renderExpandedRow} disabled={!isPaidUser(tierMatrix.accessLogs) || build === "oss"} /> ); } ================================================ FILE: src/app/[orgId]/settings/logs/action/page.tsx ================================================ "use client"; import { ColumnFilter } from "@app/components/ColumnFilter"; import { DateTimeValue } from "@app/components/DateTimePicker"; import { LogDataTable } from "@app/components/LogDataTable"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { ColumnDef } from "@tanstack/react-table"; import axios from "axios"; import { Key, User } from "lucide-react"; import { useTranslations } from "next-intl"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; export default function GeneralPage() { const router = useRouter(); const api = createApiClient(useEnvContext()); const t = useTranslations(); const { orgId } = useParams(); const searchParams = useSearchParams(); const { isPaidUser } = usePaidStatus(); const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); const [isExporting, startTransition] = useTransition(); const [filterAttributes, setFilterAttributes] = useState<{ actors: string[]; actions: string[]; }>({ actors: [], actions: [] }); // Filter states - unified object for all filters const [filters, setFilters] = useState<{ action?: string; actor?: string; }>({ action: searchParams.get("action") || undefined, actor: searchParams.get("actor") || undefined }); // Pagination state const [totalCount, setTotalCount] = useState(0); const [currentPage, setCurrentPage] = useState(0); const [isLoading, setIsLoading] = useState(false); // Initialize page size from storage or default const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20); // Set default date range to last 24 hours const getDefaultDateRange = () => { // if the time is in the url params, use that instead const startParam = searchParams.get("start"); const endParam = searchParams.get("end"); if (startParam && endParam) { return { startDate: { date: new Date(startParam) }, endDate: { date: new Date(endParam) } }; } const now = new Date(); const lastWeek = getSevenDaysAgo(); return { startDate: { date: lastWeek }, endDate: { date: now } }; }; const [dateRange, setDateRange] = useState<{ startDate: DateTimeValue; endDate: DateTimeValue; }>(getDefaultDateRange()); // Trigger search with default values on component mount useEffect(() => { if (build === "oss") { return; } const defaultRange = getDefaultDateRange(); queryDateTime( defaultRange.startDate, defaultRange.endDate, 0, pageSize ); }, [orgId]); // Re-run if orgId changes const handleDateRangeChange = ( startDate: DateTimeValue, endDate: DateTimeValue ) => { setDateRange({ startDate, endDate }); setCurrentPage(0); // Reset to first page when filtering // put the search params in the url for the time updateUrlParamsForAllFilters({ start: startDate.date?.toISOString() || "", end: endDate.date?.toISOString() || "" }); queryDateTime(startDate, endDate, 0, pageSize); }; // Handle page changes const handlePageChange = (newPage: number) => { setCurrentPage(newPage); queryDateTime( dateRange.startDate, dateRange.endDate, newPage, pageSize ); }; // Handle page size changes const handlePageSizeChange = (newPageSize: number) => { setPageSize(newPageSize); setCurrentPage(0); // Reset to first page when changing page size queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); }; // Handle filter changes generically const handleFilterChange = ( filterType: keyof typeof filters, value: string | undefined ) => { // Create new filters object with updated value const newFilters = { ...filters, [filterType]: value }; setFilters(newFilters); setCurrentPage(0); // Reset to first page when filtering // Update URL params updateUrlParamsForAllFilters(newFilters); // Trigger new query with updated filters (pass directly to avoid async state issues) queryDateTime( dateRange.startDate, dateRange.endDate, 0, pageSize, newFilters ); }; const updateUrlParamsForAllFilters = ( newFilters: | typeof filters | { start: string; end: string; } ) => { const params = new URLSearchParams(searchParams); Object.entries(newFilters).forEach(([key, value]) => { if (value) { params.set(key, value); } else { params.delete(key); } }); router.replace(`?${params.toString()}`, { scroll: false }); }; const queryDateTime = async ( startDate: DateTimeValue, endDate: DateTimeValue, page: number = currentPage, size: number = pageSize, filtersParam?: { action?: string; actor?: string; } ) => { console.log("Date range changed:", { startDate, endDate, page, size }); if (!isPaidUser(tierMatrix.actionLogs)) { console.log( "Access denied: subscription inactive or license locked" ); return; } setIsLoading(true); try { // Use the provided filters or fall back to current state const activeFilters = filtersParam || filters; // Convert the date/time values to API parameters const params: any = { limit: size, offset: page * size, ...activeFilters }; if (startDate?.date) { const startDateTime = new Date(startDate.date); if (startDate.time) { const [hours, minutes, seconds] = startDate.time .split(":") .map(Number); startDateTime.setHours(hours, minutes, seconds || 0); } params.timeStart = startDateTime.toISOString(); } if (endDate?.date) { const endDateTime = new Date(endDate.date); if (endDate.time) { const [hours, minutes, seconds] = endDate.time .split(":") .map(Number); endDateTime.setHours(hours, minutes, seconds || 0); } else { // If no time is specified, set to NOW const now = new Date(); endDateTime.setHours( now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds() ); } params.timeEnd = endDateTime.toISOString(); } const res = await api.get(`/org/${orgId}/logs/action`, { params }); if (res.status === 200) { setRows(res.data.data.log || []); setTotalCount(res.data.data.pagination?.total || 0); setFilterAttributes(res.data.data.filterAttributes); console.log("Fetched logs:", res.data); } } catch (error) { toast({ title: t("error"), description: t("Failed to filter logs"), variant: "destructive" }); } finally { setIsLoading(false); } }; const refreshData = async () => { console.log("Data refreshed"); setIsRefreshing(true); try { // Refresh data with current date range and pagination await queryDateTime( dateRange.startDate, dateRange.endDate, currentPage, pageSize ); } catch (error) { toast({ title: t("error"), description: t("refreshError"), variant: "destructive" }); } finally { setIsRefreshing(false); } }; const exportData = async () => { try { // Prepare query params for export const params: any = { timeStart: dateRange.startDate?.date ? new Date(dateRange.startDate.date).toISOString() : undefined, timeEnd: dateRange.endDate?.date ? new Date(dateRange.endDate.date).toISOString() : undefined, ...filters }; const response = await api.get(`/org/${orgId}/logs/action/export`, { responseType: "blob", params }); // Create a URL for the blob and trigger a download const url = window.URL.createObjectURL(new Blob([response.data])); const link = document.createElement("a"); link.href = url; const epoch = Math.floor(Date.now() / 1000); link.setAttribute( "download", `action-audit-logs-${orgId}-${epoch}.csv` ); document.body.appendChild(link); link.click(); link.parentNode?.removeChild(link); } catch (error) { let apiErrorMessage: string | null = null; if (axios.isAxiosError(error) && error.response) { const data = error.response.data; if (data instanceof Blob && data.type === "application/json") { // Parse the Blob as JSON const text = await data.text(); const errorData = JSON.parse(text); apiErrorMessage = errorData.message; } } toast({ title: t("error"), description: apiErrorMessage ?? t("exportError"), variant: "destructive" }); } }; const columns: ColumnDef[] = [ { accessorKey: "timestamp", header: ({ column }) => { return t("timestamp"); }, cell: ({ row }) => { return (
{new Date( row.original.timestamp * 1000 ).toLocaleString()}
); } }, { accessorKey: "action", header: ({ column }) => { return (
{t("action")} ({ label: action.charAt(0).toUpperCase() + action.slice(1), value: action }))} selectedValue={filters.action} onValueChange={(value) => handleFilterChange("action", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( {row.original.action.charAt(0).toUpperCase() + row.original.action.slice(1)} ); } }, { accessorKey: "actor", header: ({ column }) => { return (
{t("actor")} ({ value: actor, label: actor }))} selectedValue={filters.actor} onValueChange={(value) => handleFilterChange("actor", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( {row.original.actorType == "user" ? ( ) : ( )} {row.original.actor} ); } }, { accessorKey: "actorId", header: ({ column }) => { return t("actorId"); }, cell: ({ row }) => { return ( {row.original.actorId} ); } } ]; const renderExpandedRow = (row: any) => { return (
Metadata:
                            {row.metadata
                                ? JSON.stringify(
                                      JSON.parse(row.metadata),
                                      null,
                                      2
                                  )
                                : "N/A"}
                        
); }; return ( <> startTransition(exportData)} // isExportDisabled={ // not disabling this because the user should be able to click the button and get the feedback about needing to upgrade the plan // !isPaidUser(tierMatrix.logExport) || build === "oss" // } isExporting={isExporting} onDateRangeChange={handleDateRangeChange} dateRange={{ start: dateRange.startDate, end: dateRange.endDate }} defaultSort={{ id: "timestamp", desc: true }} // Server-side pagination props totalCount={totalCount} currentPage={currentPage} pageSize={pageSize} onPageChange={handlePageChange} onPageSizeChange={handlePageSizeChange} isLoading={isLoading} // Row expansion props expandable={true} renderExpandedRow={renderExpandedRow} disabled={!isPaidUser(tierMatrix.actionLogs) || build === "oss"} /> ); } ================================================ FILE: src/app/[orgId]/settings/logs/analytics/page.tsx ================================================ import { LogAnalyticsData } from "@app/components/LogAnalyticsData"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; export interface AnalyticsPageProps { params: Promise<{ orgId: string }>; searchParams: Promise>; } export default async function AnalyticsPage(props: AnalyticsPageProps) { const t = await getTranslations(); const orgId = (await props.params).orgId; return ( <>
); } ================================================ FILE: src/app/[orgId]/settings/logs/layout.tsx ================================================ import { verifySession } from "@app/lib/auth/verifySession"; import { redirect } from "next/navigation"; import { cache } from "react"; type GeneralSettingsProps = { children: React.ReactNode; params: Promise<{ orgId: string }>; }; export default async function GeneralSettingsPage({ children, params }: GeneralSettingsProps) { const getUser = cache(verifySession); const user = await getUser(); if (!user) { redirect(`/`); } return children; } ================================================ FILE: src/app/[orgId]/settings/logs/page.tsx ================================================ export default function GeneralPage() { return null; } ================================================ FILE: src/app/[orgId]/settings/logs/request/page.tsx ================================================ "use client"; import { ColumnFilter } from "@app/components/ColumnFilter"; import { DateTimeValue } from "@app/components/DateTimePicker"; import { LogDataTable } from "@app/components/LogDataTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { Button } from "@app/components/ui/button"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; import { useTranslations } from "next-intl"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { ColumnDef } from "@tanstack/react-table"; import axios from "axios"; import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react"; import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { build } from "@server/build"; export default function GeneralPage() { const router = useRouter(); const api = createApiClient(useEnvContext()); const t = useTranslations(); const { orgId } = useParams(); const searchParams = useSearchParams(); const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); const [isExporting, startTransition] = useTransition(); // Pagination state const [totalCount, setTotalCount] = useState(0); const [currentPage, setCurrentPage] = useState(0); const [isLoading, setIsLoading] = useState(false); // Initialize page size from storage or default const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20); const [filterAttributes, setFilterAttributes] = useState<{ actors: string[]; resources: { id: number; name: string | null; }[]; locations: string[]; hosts: string[]; paths: string[]; }>({ actors: [], resources: [], locations: [], hosts: [], paths: [] }); // Filter states - unified object for all filters const [filters, setFilters] = useState<{ action?: string; resourceId?: string; host?: string; location?: string; actor?: string; method?: string; reason?: string; path?: string; }>({ action: searchParams.get("action") || undefined, host: searchParams.get("host") || undefined, resourceId: searchParams.get("resourceId") || undefined, location: searchParams.get("location") || undefined, actor: searchParams.get("actor") || undefined, method: searchParams.get("method") || undefined, reason: searchParams.get("reason") || undefined, path: searchParams.get("path") || undefined }); // Set default date range to last 24 hours const getDefaultDateRange = () => { // if the time is in the url params, use that instead const startParam = searchParams.get("start"); const endParam = searchParams.get("end"); if (startParam && endParam) { return { startDate: { date: new Date(startParam) }, endDate: { date: new Date(endParam) } }; } const now = new Date(); const lastWeek = getSevenDaysAgo(); return { startDate: { date: lastWeek }, endDate: { date: now } }; }; const [dateRange, setDateRange] = useState<{ startDate: DateTimeValue; endDate: DateTimeValue; }>(getDefaultDateRange()); // Trigger search with default values on component mount useEffect(() => { if (build === "oss") { return; } const defaultRange = getDefaultDateRange(); queryDateTime( defaultRange.startDate, defaultRange.endDate, 0, pageSize ); }, [orgId]); // Re-run if orgId changes const handleDateRangeChange = ( startDate: DateTimeValue, endDate: DateTimeValue ) => { setDateRange({ startDate, endDate }); setCurrentPage(0); // Reset to first page when filtering // put the search params in the url for the time updateUrlParamsForAllFilters({ start: startDate.date?.toISOString() || "", end: endDate.date?.toISOString() || "" }); queryDateTime(startDate, endDate, 0, pageSize); }; // Handle page changes const handlePageChange = (newPage: number) => { setCurrentPage(newPage); queryDateTime( dateRange.startDate, dateRange.endDate, newPage, pageSize ); }; // Handle page size changes const handlePageSizeChange = (newPageSize: number) => { setPageSize(newPageSize); setCurrentPage(0); // Reset to first page when changing page size queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); }; // Handle filter changes generically const handleFilterChange = ( filterType: keyof typeof filters, value: string | undefined ) => { console.log(`${filterType} filter changed:`, value); // Create new filters object with updated value const newFilters = { ...filters, [filterType]: value }; setFilters(newFilters); setCurrentPage(0); // Reset to first page when filtering // Update URL params updateUrlParamsForAllFilters(newFilters); // Trigger new query with updated filters (pass directly to avoid async state issues) queryDateTime( dateRange.startDate, dateRange.endDate, 0, pageSize, newFilters ); }; const updateUrlParamsForAllFilters = ( newFilters: | typeof filters | { start: string; end: string; } ) => { const params = new URLSearchParams(searchParams); Object.entries(newFilters).forEach(([key, value]) => { if (value) { params.set(key, value); } else { params.delete(key); } }); router.replace(`?${params.toString()}`, { scroll: false }); }; const queryDateTime = async ( startDate: DateTimeValue, endDate: DateTimeValue, page: number = currentPage, size: number = pageSize, filtersParam?: { action?: string; type?: string; } ) => { console.log("Date range changed:", { startDate, endDate, page, size }); setIsLoading(true); try { // Use the provided filters or fall back to current state const activeFilters = filtersParam || filters; // Convert the date/time values to API parameters const params: any = { limit: size, offset: page * size, ...activeFilters }; if (startDate?.date) { const startDateTime = new Date(startDate.date); if (startDate.time) { const [hours, minutes, seconds] = startDate.time .split(":") .map(Number); startDateTime.setHours(hours, minutes, seconds || 0); } params.timeStart = startDateTime.toISOString(); } if (endDate?.date) { const endDateTime = new Date(endDate.date); if (endDate.time) { const [hours, minutes, seconds] = endDate.time .split(":") .map(Number); endDateTime.setHours(hours, minutes, seconds || 0); } else { // If no time is specified, set to NOW const now = new Date(); endDateTime.setHours( now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds() ); } params.timeEnd = endDateTime.toISOString(); } const res = await api.get(`/org/${orgId}/logs/request`, { params }); if (res.status === 200) { setRows(res.data.data.log || []); setTotalCount(res.data.data.pagination?.total || 0); setFilterAttributes(res.data.data.filterAttributes); console.log("Fetched logs:", res.data); } } catch (error) { toast({ title: t("error"), description: t("Failed to filter logs"), variant: "destructive" }); } finally { setIsLoading(false); } }; const refreshData = async () => { console.log("Data refreshed"); setIsRefreshing(true); try { // Refresh data with current date range and pagination await queryDateTime( dateRange.startDate, dateRange.endDate, currentPage, pageSize ); } catch (error) { toast({ title: t("error"), description: t("refreshError"), variant: "destructive" }); } finally { setIsRefreshing(false); } }; const exportData = async () => { try { // Prepare query params for export const params: any = { timeStart: dateRange.startDate?.date ? new Date(dateRange.startDate.date).toISOString() : undefined, timeEnd: dateRange.endDate?.date ? new Date(dateRange.endDate.date).toISOString() : undefined, ...filters }; const response = await api.get( `/org/${orgId}/logs/request/export`, { responseType: "blob", params } ); // Create a URL for the blob and trigger a download const url = window.URL.createObjectURL(new Blob([response.data])); const link = document.createElement("a"); link.href = url; const epoch = Math.floor(Date.now() / 1000); link.setAttribute( "download", `request-audit-logs-${orgId}-${epoch}.csv` ); document.body.appendChild(link); link.click(); link.parentNode?.removeChild(link); } catch (error) { let apiErrorMessage: string | null = null; if (axios.isAxiosError(error) && error.response) { const data = error.response.data; if (data instanceof Blob && data.type === "application/json") { // Parse the Blob as JSON const text = await data.text(); const errorData = JSON.parse(text); apiErrorMessage = errorData.message; } } toast({ title: t("error"), description: apiErrorMessage ?? t("exportError"), variant: "destructive" }); } }; // 100 - Allowed by Rule // 101 - Allowed No Auth // 102 - Valid Access Token // 103 - Valid header auth // 104 - Valid Pincode // 105 - Valid Password // 106 - Valid email // 107 - Valid SSO // 201 - Resource Not Found // 202 - Resource Blocked // 203 - Dropped by Rule // 204 - No Sessions // 205 - Temporary Request Token // 299 - No More Auth Methods const reasonMap: any = { 100: t("allowedByRule"), 101: t("allowedNoAuth"), 102: t("validAccessToken"), 103: t("validHeaderAuth"), 104: t("validPincode"), 105: t("validPassword"), 106: t("validEmail"), 107: t("validSSO"), 201: t("resourceNotFound"), 202: t("resourceBlocked"), 203: t("droppedByRule"), 204: t("noSessions"), 205: t("temporaryRequestToken"), 299: t("noMoreAuthMethods") }; // resourceId: integer("resourceId"), // userAgent: text("userAgent"), // metadata: text("details"), // headers: text("headers"), // JSON blob // query: text("query"), // JSON blob // originalRequestURL: text("originalRequestURL"), // scheme: text("scheme"), const columns: ColumnDef[] = [ { accessorKey: "timestamp", header: ({ column }) => { return t("timestamp"); }, cell: ({ row }) => { return (
{new Date( row.original.timestamp * 1000 ).toLocaleString()}
); } }, { accessorKey: "action", header: ({ column }) => { return (
{t("action")} handleFilterChange("action", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( {row.original.action ? <>Allowed : <>Denied} ); } }, { accessorKey: "ip", header: ({ column }) => { return t("ip"); } }, { accessorKey: "location", header: ({ column }) => { return (
{t("location")} ({ value: location, label: location }) )} selectedValue={filters.location} onValueChange={(value) => handleFilterChange("location", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( {row.original.location ? ( {row.original.location} ) : ( - )} ); } }, { accessorKey: "resourceName", header: ({ column }) => { return (
{t("resource")} ({ value: res.id.toString(), label: res.name || "Unnamed Resource" }))} selectedValue={filters.resourceId} onValueChange={(value) => handleFilterChange("resourceId", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( e.stopPropagation()} > ); } }, { accessorKey: "host", header: ({ column }) => { return (
{t("host")} ({ value: host, label: host }))} selectedValue={filters.host} onValueChange={(value) => handleFilterChange("host", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( {row.original.tls ? ( ) : ( )} {row.original.host} ); } }, { accessorKey: "path", header: ({ column }) => { return (
{t("path")} ({ value: path, label: path }))} selectedValue={filters.path} onValueChange={(value) => handleFilterChange("path", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); } }, // { // accessorKey: "scheme", // header: ({ column }) => { // return t("scheme"); // }, // }, { accessorKey: "method", header: ({ column }) => { return (
{t("method")} handleFilterChange("method", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); } }, { accessorKey: "reason", header: ({ column }) => { return (
{t("reason")} handleFilterChange("reason", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( {reasonMap[row.original.reason]} ); } }, { accessorKey: "actor", header: ({ column }) => { return (
{t("actor")} ({ value: actor, label: actor }))} selectedValue={filters.actor} onValueChange={(value) => handleFilterChange("actor", value) } // placeholder="" searchPlaceholder="Search..." emptyMessage="None found" />
); }, cell: ({ row }) => { return ( {row.original.actor ? ( <> {row.original.actorType == "user" ? ( ) : ( )} {row.original.actor} ) : ( <>- )} ); } } ]; const renderExpandedRow = (row: any) => { return (
{/*
User Agent:

{row.userAgent || "N/A"}

*/}
Original URL:

{row.originalRequestURL || "N/A"}

{/*
Scheme:

{row.scheme || "N/A"}

*/}
Metadata:
                            {row.metadata
                                ? JSON.stringify(
                                      JSON.parse(row.metadata),
                                      null,
                                      2
                                  )
                                : "N/A"}
                        
{row.headers && (
Headers:
                                {JSON.stringify(
                                    JSON.parse(row.headers),
                                    null,
                                    2
                                )}
                            
)} {row.query && (
Query Parameters:
                                {JSON.stringify(JSON.parse(row.query), null, 2)}
                            
)}
); }; return ( <> startTransition(exportData)} isExporting={isExporting} onDateRangeChange={handleDateRangeChange} dateRange={{ start: dateRange.startDate, end: dateRange.endDate }} defaultSort={{ id: "timestamp", desc: true }} // Server-side pagination props totalCount={totalCount} currentPage={currentPage} onPageChange={handlePageChange} onPageSizeChange={handlePageSizeChange} isLoading={isLoading} pageSize={pageSize} // Row expansion props expandable={true} renderExpandedRow={renderExpandedRow} /> ); } ================================================ FILE: src/app/[orgId]/settings/not-found.tsx ================================================ import { getTranslations } from "next-intl/server"; export default async function NotFound() { const t = await getTranslations(); return (

404

{t("pageNotFound")}

{t("pageNotFoundDescription")}

); } ================================================ FILE: src/app/[orgId]/settings/page.tsx ================================================ import { redirect } from "next/navigation"; type OrgPageProps = { params: Promise<{ orgId: string }>; }; export default async function SettingsPage(props: OrgPageProps) { const params = await props.params; redirect(`/${params.orgId}/settings/sites`); return <>; } ================================================ FILE: src/app/[orgId]/settings/resources/client/page.tsx ================================================ import type { InternalResourceRow } from "@app/components/ClientResourcesTable"; import ClientResourcesTable from "@app/components/ClientResourcesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; import type { ListResourcesResponse } from "@server/routers/resource"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; export interface ClientResourcesPageProps { params: Promise<{ orgId: string }>; searchParams: Promise>; } export default async function ClientResourcesPage( props: ClientResourcesPageProps ) { const params = await props.params; const t = await getTranslations(); const searchParams = new URLSearchParams(await props.searchParams); let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; let pagination: ListResourcesResponse["pagination"] = { total: 0, page: 1, pageSize: 20 }; try { const res = await internal.get< AxiosResponse >( `/org/${params.orgId}/site-resources?${searchParams.toString()}`, await authCookieHeader() ); const responseData = res.data.data; siteResources = responseData.siteResources; pagination = responseData.pagination; } catch (e) {} let org = null; try { const res = await getCachedOrg(params.orgId); org = res.data.data; } catch { redirect(`/${params.orgId}/settings/resources`); } if (!org) { redirect(`/${params.orgId}/settings/resources`); } const internalResourceRows: InternalResourceRow[] = siteResources.map( (siteResource) => { return { id: siteResource.siteResourceId, name: siteResource.name, orgId: params.orgId, siteName: siteResource.siteName, siteAddress: siteResource.siteAddress || null, mode: siteResource.mode || ("port" as any), // protocol: siteResource.protocol, // proxyPort: siteResource.proxyPort, siteId: siteResource.siteId, destination: siteResource.destination, // destinationPort: siteResource.destinationPort, alias: siteResource.alias || null, aliasAddress: siteResource.aliasAddress || null, siteNiceId: siteResource.siteNiceId, niceId: siteResource.niceId, tcpPortRangeString: siteResource.tcpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null, disableIcmp: siteResource.disableIcmp || false, authDaemonMode: siteResource.authDaemonMode ?? null, authDaemonPort: siteResource.authDaemonPort ?? null }; } ); return ( <> ); } ================================================ FILE: src/app/[orgId]/settings/resources/page.tsx ================================================ import { redirect } from "next/navigation"; export interface ResourcesPageProps { params: Promise<{ orgId: string }>; } export default async function ResourcesPage(props: ResourcesPageProps) { const params = await props.params; redirect(`/${params.orgId}/settings/resources/proxy`); } ================================================ FILE: src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx ================================================ "use client"; import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm"; import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@app/components/ui/form"; import { InfoPopup } from "@app/components/ui/info-popup"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@app/components/ui/select"; import type { ResourceContextType } from "@app/contexts/resourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useOrgContext } from "@app/hooks/useOrgContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { orgQueries, resourceQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { UserType } from "@server/types/UserTypes"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import SetResourcePasswordForm from "components/SetResourcePasswordForm"; import { Binary, Bot, InfoIcon, Key } from "lucide-react"; import { useTranslations } from "next-intl"; import { useRouter } from "next/navigation"; import { useActionState, useEffect, useMemo, useRef, useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; const UsersRolesFormSchema = z.object({ roles: z.array( z.object({ id: z.string(), text: z.string() }) ), users: z.array( z.object({ id: z.string(), text: z.string() }) ) }); const whitelistSchema = z.object({ emails: z.array( z.object({ id: z.string(), text: z.string() }) ) }); export default function ResourceAuthenticationPage() { const { org } = useOrgContext(); const { resource, updateResource, authInfo, updateAuthInfo } = useResourceContext(); const { env } = useEnvContext(); const api = createApiClient({ env }); const router = useRouter(); const t = useTranslations(); const { isPaidUser } = usePaidStatus(); const queryClient = useQueryClient(); const { data: resourceRoles = [], isLoading: isLoadingResourceRoles } = useQuery( resourceQueries.resourceRoles({ resourceId: resource.resourceId }) ); const { data: resourceUsers = [], isLoading: isLoadingResourceUsers } = useQuery( resourceQueries.resourceUsers({ resourceId: resource.resourceId }) ); const { data: whitelist = [], isLoading: isLoadingWhiteList } = useQuery( resourceQueries.resourceWhitelist({ resourceId: resource.resourceId }) ); const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( orgQueries.roles({ orgId: org.org.orgId }) ); const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery( orgQueries.users({ orgId: org.org.orgId }) ); const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( orgQueries.identityProviders({ orgId: org.org.orgId, useOrgOnlyIdp: env.app.identityProviderMode === "org" }) ); const pageLoading = isLoadingOrgRoles || isLoadingOrgUsers || isLoadingResourceRoles || isLoadingResourceUsers || isLoadingWhiteList || isLoadingOrgIdps; const allRoles = useMemo(() => { return orgRoles .map((role) => ({ id: role.roleId.toString(), text: role.name })) .filter((role) => role.text !== "Admin"); }, [orgRoles]); const allUsers = useMemo(() => { return orgUsers.map((user) => ({ id: user.id.toString(), text: `${getUserDisplayName({ email: user.email, username: user.username })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` })); }, [orgUsers]); const allIdps = useMemo(() => { if (build === "saas") { if (isPaidUser(tierMatrix.orgOidc)) { return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name })); } } else { return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name })); } return []; }, [orgIdps]); const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< number | null >(null); const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false); useEffect(() => { setSsoEnabled(resource.sso ?? false); }, [resource.sso]); const [selectedIdpId, setSelectedIdpId] = useState( resource.skipToIdpId || null ); const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] = useState(false); const [loadingRemoveResourcePincode, setLoadingRemoveResourcePincode] = useState(false); const [ loadingRemoveResourceHeaderAuth, setLoadingRemoveResourceHeaderAuth ] = useState(false); const [isSetPasswordOpen, setIsSetPasswordOpen] = useState(false); const [isSetPincodeOpen, setIsSetPincodeOpen] = useState(false); const [isSetHeaderAuthOpen, setIsSetHeaderAuthOpen] = useState(false); const usersRolesForm = useForm({ resolver: zodResolver(UsersRolesFormSchema), defaultValues: { roles: [], users: [] } }); const whitelistForm = useForm({ resolver: zodResolver(whitelistSchema), defaultValues: { emails: [] } }); const hasInitializedRef = useRef(false); useEffect(() => { if (pageLoading || hasInitializedRef.current) return; usersRolesForm.setValue( "roles", resourceRoles .map((i) => ({ id: i.roleId.toString(), text: i.name })) .filter((role) => role.text !== "Admin") ); usersRolesForm.setValue( "users", resourceUsers.map((i) => ({ id: i.userId.toString(), text: `${getUserDisplayName({ email: i.email, username: i.username })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` })) ); whitelistForm.setValue( "emails", whitelist.map((w) => ({ id: w.email, text: w.email })) ); hasInitializedRef.current = true; }, [pageLoading, resourceRoles, resourceUsers, whitelist, orgIdps]); const [, submitUserRolesForm, loadingSaveUsersRoles] = useActionState( onSubmitUsersRoles, null ); async function onSubmitUsersRoles() { const isValid = usersRolesForm.trigger(); if (!isValid) return; const data = usersRolesForm.getValues(); try { const jobs = [ api.post(`/resource/${resource.resourceId}/roles`, { roleIds: data.roles.map((i) => parseInt(i.id)) }), api.post(`/resource/${resource.resourceId}/users`, { userIds: data.users.map((i) => i.id) }), api.post(`/resource/${resource.resourceId}`, { sso: ssoEnabled, skipToIdpId: selectedIdpId }) ]; await Promise.all(jobs); updateResource({ sso: ssoEnabled, skipToIdpId: selectedIdpId }); updateAuthInfo({ sso: ssoEnabled }); toast({ title: t("resourceAuthSettingsSave"), description: t("resourceAuthSettingsSaveDescription") }); // invalidate resource queries await queryClient.invalidateQueries( resourceQueries.resourceUsers({ resourceId: resource.resourceId }) ); await queryClient.invalidateQueries( resourceQueries.resourceRoles({ resourceId: resource.resourceId }) ); router.refresh(); } catch (e) { console.error(e); toast({ variant: "destructive", title: t("resourceErrorUsersRolesSave"), description: formatAxiosError( e, t("resourceErrorUsersRolesSaveDescription") ) }); } } function removeResourcePassword() { setLoadingRemoveResourcePassword(true); api.post(`/resource/${resource.resourceId}/password`, { password: null }) .then(() => { toast({ title: t("resourcePasswordRemove"), description: t("resourcePasswordRemoveDescription") }); updateAuthInfo({ password: false }); router.refresh(); }) .catch((e) => { toast({ variant: "destructive", title: t("resourceErrorPasswordRemove"), description: formatAxiosError( e, t("resourceErrorPasswordRemoveDescription") ) }); }) .finally(() => setLoadingRemoveResourcePassword(false)); } function removeResourcePincode() { setLoadingRemoveResourcePincode(true); api.post(`/resource/${resource.resourceId}/pincode`, { pincode: null }) .then(() => { toast({ title: t("resourcePincodeRemove"), description: t("resourcePincodeRemoveDescription") }); updateAuthInfo({ pincode: false }); router.refresh(); }) .catch((e) => { toast({ variant: "destructive", title: t("resourceErrorPincodeRemove"), description: formatAxiosError( e, t("resourceErrorPincodeRemoveDescription") ) }); }) .finally(() => setLoadingRemoveResourcePincode(false)); } function removeResourceHeaderAuth() { setLoadingRemoveResourceHeaderAuth(true); api.post(`/resource/${resource.resourceId}/header-auth`, { user: null, password: null, extendedCompatibility: null }) .then(() => { toast({ title: t("resourceHeaderAuthRemove"), description: t("resourceHeaderAuthRemoveDescription") }); updateAuthInfo({ headerAuth: false }); router.refresh(); }) .catch((e) => { toast({ variant: "destructive", title: t("resourceErrorHeaderAuthRemove"), description: formatAxiosError( e, t("resourceErrorHeaderAuthRemoveDescription") ) }); }) .finally(() => setLoadingRemoveResourceHeaderAuth(false)); } if (pageLoading) { return <>; } return ( <> {isSetPasswordOpen && ( { setIsSetPasswordOpen(false); updateAuthInfo({ password: true }); }} /> )} {isSetPincodeOpen && ( { setIsSetPincodeOpen(false); updateAuthInfo({ pincode: true }); }} /> )} {isSetHeaderAuthOpen && ( { setIsSetHeaderAuthOpen(false); updateAuthInfo({ headerAuth: true }); }} /> )} {t("resourceUsersRoles")} {t("resourceUsersRolesDescription")} setSsoEnabled(val)} />
{ssoEnabled && ( <> ( {t("roles")} { usersRolesForm.setValue( "roles", newRoles as [ Tag, ...Tag[] ] ); }} enableAutocomplete={ true } autocompleteOptions={ allRoles } allowDuplicates={ false } restrictTagsToAutocompleteOptions={ true } sortTags={true} /> {t( "resourceRoleDescription" )} )} /> ( {t("users")} { usersRolesForm.setValue( "users", newUsers as [ Tag, ...Tag[] ] ); }} enableAutocomplete={ true } autocompleteOptions={ allUsers } allowDuplicates={ false } restrictTagsToAutocompleteOptions={ true } sortTags={true} /> )} /> )} {ssoEnabled && allIdps.length > 0 && (

{t( "defaultIdentityProviderDescription" )}

)}
{t("resourceAuthMethods")} {t("resourceAuthMethodsDescriptions")} {/* Password Protection */}
{t("resourcePasswordProtection", { status: authInfo.password ? t("enabled") : t("disabled") })}
{/* PIN Code Protection */}
{t("resourcePincodeProtection", { status: authInfo.pincode ? t("enabled") : t("disabled") })}
{/* Header Authentication Protection */}
{authInfo.headerAuth ? t( "resourceHeaderAuthProtectionEnabled" ) : t( "resourceHeaderAuthProtectionDisabled" )}
); } type OneTimePasswordFormSectionProps = Pick< ResourceContextType, "resource" | "updateResource" > & { whitelist: Array<{ email: string }>; isLoadingWhiteList: boolean; }; function OneTimePasswordFormSection({ resource, updateResource, whitelist, isLoadingWhiteList }: OneTimePasswordFormSectionProps) { const { env } = useEnvContext(); const [whitelistEnabled, setWhitelistEnabled] = useState( resource.emailWhitelistEnabled ?? false ); useEffect(() => { setWhitelistEnabled(resource.emailWhitelistEnabled); }, [resource.emailWhitelistEnabled]); const queryClient = useQueryClient(); const [loadingSaveWhitelist, startTransition] = useTransition(); const whitelistForm = useForm({ resolver: zodResolver(whitelistSchema), defaultValues: { emails: [] } }); const api = createApiClient({ env }); const router = useRouter(); const t = useTranslations(); const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< number | null >(null); useEffect(() => { if (isLoadingWhiteList) return; whitelistForm.setValue( "emails", whitelist.map((w) => ({ id: w.email, text: w.email })) ); }, [isLoadingWhiteList, whitelist, whitelistForm]); async function saveWhitelist() { try { await api.post(`/resource/${resource.resourceId}`, { emailWhitelistEnabled: whitelistEnabled }); if (whitelistEnabled) { await api.post(`/resource/${resource.resourceId}/whitelist`, { emails: whitelistForm.getValues().emails.map((i) => i.text) }); } updateResource({ emailWhitelistEnabled: whitelistEnabled }); toast({ title: t("resourceWhitelistSave"), description: t("resourceWhitelistSaveDescription") }); router.refresh(); await queryClient.invalidateQueries( resourceQueries.resourceWhitelist({ resourceId: resource.resourceId }) ); } catch (e) { console.error(e); toast({ variant: "destructive", title: t("resourceErrorWhitelistSave"), description: formatAxiosError( e, t("resourceErrorWhitelistSaveDescription") ) }); } } return ( {t("otpEmailTitle")} {t("otpEmailTitleDescription")} {!env.email.emailEnabled && ( {t("otpEmailSmtpRequired")} {t("otpEmailSmtpRequiredDescription")} )} {whitelistEnabled && env.email.emailEnabled && (
( {/* @ts-ignore */} { return z .email() .or( z .string() .regex( /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, { message: t( "otpEmailErrorInvalid" ) } ) ) .safeParse(tag) .success; }} setActiveTagIndex={ setActiveEmailTagIndex } placeholder={t( "otpEmailEnter" )} tags={ whitelistForm.getValues() .emails } setTags={(newRoles) => { whitelistForm.setValue( "emails", newRoles as [ Tag, ...Tag[] ] ); }} allowDuplicates={false} sortTags={true} /> {t("otpEmailEnterDescription")} )} /> )}
); } ================================================ FILE: src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx ================================================ "use client"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { useResourceContext } from "@app/hooks/useResourceContext"; import { Credenza, CredenzaBody, CredenzaClose, CredenzaContent, CredenzaDescription, CredenzaFooter, CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; import DomainPicker from "@app/components/DomainPicker"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; import { Label } from "@app/components/ui/label"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { UpdateResourceResponse } from "@server/routers/resource"; import { AxiosResponse } from "axios"; import { AlertCircle, Globe } from "lucide-react"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; import { toASCII, toUnicode } from "punycode"; import { useActionState, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import z from "zod"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; import { Tooltip, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { GetResourceResponse } from "@server/routers/resource/getResource"; import type { ResourceContextType } from "@app/contexts/resourceContext"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; type MaintenanceSectionFormProps = { resource: GetResourceResponse; updateResource: ResourceContextType["updateResource"]; }; function MaintenanceSectionForm({ resource, updateResource }: MaintenanceSectionFormProps) { const { env } = useEnvContext(); const t = useTranslations(); const api = createApiClient({ env }); const { isPaidUser } = usePaidStatus(); const MaintenanceFormSchema = z.object({ maintenanceModeEnabled: z.boolean().optional(), maintenanceModeType: z.enum(["forced", "automatic"]).optional(), maintenanceTitle: z.string().max(255).optional(), maintenanceMessage: z.string().max(2000).optional(), maintenanceEstimatedTime: z.string().max(100).optional() }); const maintenanceForm = useForm({ resolver: zodResolver(MaintenanceFormSchema), defaultValues: { maintenanceModeEnabled: resource.maintenanceModeEnabled || false, maintenanceModeType: resource.maintenanceModeType || "automatic", maintenanceTitle: resource.maintenanceTitle || "We'll be back soon!", maintenanceMessage: resource.maintenanceMessage || "We are currently performing scheduled maintenance. Please check back soon.", maintenanceEstimatedTime: resource.maintenanceEstimatedTime || "" }, mode: "onChange" }); const isMaintenanceEnabled = maintenanceForm.watch( "maintenanceModeEnabled" ); const maintenanceModeType = maintenanceForm.watch("maintenanceModeType"); const [, maintenanceFormAction, maintenanceSaveLoading] = useActionState( onMaintenanceSubmit, null ); async function onMaintenanceSubmit() { const isValid = await maintenanceForm.trigger(); if (!isValid) return; const data = maintenanceForm.getValues(); const res = await api .post>( `resource/${resource?.resourceId}`, { maintenanceModeEnabled: data.maintenanceModeEnabled, maintenanceModeType: data.maintenanceModeType, maintenanceTitle: data.maintenanceTitle || null, maintenanceMessage: data.maintenanceMessage || null, maintenanceEstimatedTime: data.maintenanceEstimatedTime || null } ) .catch((e) => { toast({ variant: "destructive", title: t("resourceErrorUpdate"), description: formatAxiosError( e, t("resourceErrorUpdateDescription") ) }); }); if (res && res.status === 200) { updateResource({ maintenanceModeEnabled: data.maintenanceModeEnabled, maintenanceModeType: data.maintenanceModeType, maintenanceTitle: data.maintenanceTitle || null, maintenanceMessage: data.maintenanceMessage || null, maintenanceEstimatedTime: data.maintenanceEstimatedTime || null }); toast({ title: t("resourceUpdated"), description: t("resourceUpdatedDescription") }); } } if (!resource.http) { return null; } return ( {t("maintenanceMode")} {t("maintenanceModeDescription")}
{ const isDisabled = !isPaidUser(tierMatrix.maintencePage) || resource.http === false; return (
{ if ( !isDisabled ) { maintenanceForm.setValue( "maintenanceModeEnabled", val ); } }} />
); }} /> {isMaintenanceEnabled && (
( {t("maintenanceModeType")}
{t( "automatic" )} {" "} ( {t( "recommended" )} ) {t( "automaticModeDescription" )}
{t( "forced" )} {t( "forcedModeDescription" )}
)} /> {maintenanceModeType === "forced" && ( {t("forcedeModeWarning")} )} ( {t("pageTitle")} {t("pageTitleDescription")} )} /> ( {t( "maintenancePageMessage" )}