feat: move acme certificate automation into a plugin (C7)

This commit is contained in:
LukeGus committed 2026-09-24 21:19:27 -05:00
1 parent 4dbce08379
commit 8c1e02af89
123 files changed
+4808 -1912

No files matched your search

+52 -49
View File
@@ -41,7 +41,8 @@ import { DataCrypto } from "../utils/data-crypto.js";
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
import { DatabaseMigration } from "../utils/database-migration.js";
import { UserDataExport } from "../utils/user-data-export.js";
import { AutoSSLSetup } from "../utils/auto-ssl-setup.js";
import { configureDirectHttps, getTlsConfig } from "../tls/tls-service.js";
import { acmeChallengeHandler } from "../tls/acme-challenges.js";
import {
createCurrentCredentialRepository,
createCurrentDismissedAlertRepository,
@@ -302,6 +303,28 @@ app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
/**
* @openapi
* /.well-known/acme-challenge/{token}:
* get:
* summary: Answer an ACME http-01 challenge
* description: Public. Serves the key authorization a plugin published through ctx.system.publishHttpChallenge while it proves control of the domain.
* tags:
* - General
* parameters:
* - in: path
* name: token
* required: true
* schema:
* type: string
* responses:
* 200:
* description: The key authorization, as text/plain.
* 404:
* description: No challenge is published for this token.
*/
app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
/**
* @openapi
* /version:
@@ -2089,7 +2112,7 @@ export const serverReady = new Promise<void>((resolve) => {
});
});
const sslConfig = AutoSSLSetup.getSSLConfig();
const sslConfig = getTlsConfig();
if (sslConfig.enabled) {
databaseLogger.info(`SSL is enabled`, {
operation: "ssl_info",
@@ -2098,59 +2121,39 @@ if (sslConfig.enabled) {
});
}
if (
sslConfig.enabled &&
process.env.TERMIX_SSL_TERMINATED_BY_NGINX !== "true"
) {
try {
const httpsServer = https.createServer(
{
cert: fs.readFileSync(sslConfig.certPath),
key: fs.readFileSync(sslConfig.keyPath),
},
app,
);
// Built through the TLS service so a new certificate can be swapped in, or
// HTTPS started, without a restart.
void configureDirectHttps((options) => {
const port = getTlsConfig().port;
const httpsServer = https.createServer(options, app);
attachPluginWebSockets(httpsServer);
attachPluginWebSockets(httpsServer);
httpsServer.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
databaseLogger.error(
`SSL port ${sslConfig.port} is already in use. Kill the existing process and retry.`,
err,
{
operation: "https_server_port_conflict",
port: sslConfig.port,
},
);
return;
}
databaseLogger.error("HTTPS server error", err, {
operation: "https_server_error",
});
});
httpsServer.listen(sslConfig.port, "127.0.0.1", () => {
databaseLogger.success(
`Backend is now also listening for HTTPS directly`,
httpsServer.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
databaseLogger.error(
`SSL port ${port} is already in use. Kill the existing process and retry.`,
err,
{
operation: "https_server_started",
port: sslConfig.port,
operation: "https_server_port_conflict",
port,
},
);
return;
}
databaseLogger.error("HTTPS server error", err, {
operation: "https_server_error",
});
} catch (error) {
databaseLogger.error(
"Failed to start HTTPS server with configured SSL certificate",
error,
{
operation: "https_server_start_failed",
cert_path: sslConfig.certPath,
key_path: sslConfig.keyPath,
},
);
}
}
});
httpsServer.listen(port, "127.0.0.1", () => {
databaseLogger.success(`Backend is now also listening for HTTPS directly`, {
operation: "https_server_started",
port,
});
});
return httpsServer;
});
/**
* Status check columns from an export row. Exports from before 2.9.0 only
@@ -1,586 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import { execFileSync } from "child_process";
import { promises as fs } from "fs";
import path from "path";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { reloadNginxWithSSL } from "../../utils/nginx-ssl-reload.js";
import {
createCurrentSettingsRepository,
createCurrentUserRepository,
} from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
const DATA_DIR = process.env.DATA_DIR || "./db/data";
const SSL_DIR = path.join(DATA_DIR, "ssl");
const ACME_WEBROOT = path.join(DATA_DIR, "acme-webroot");
const CERTBOT_DIR = path.join(DATA_DIR, "certbot");
const CERTBOT_CONFIG_DIR = path.join(CERTBOT_DIR, "config");
const CERTBOT_WORK_DIR = path.join(CERTBOT_DIR, "work");
const CERTBOT_LOGS_DIR = path.join(CERTBOT_DIR, "logs");
const CLOUDFLARE_CREDENTIALS_FILE = path.join(
DATA_DIR,
"ssl",
"cloudflare.ini",
);
export type AcmeSettings = {
enabled: boolean;
domain: string;
email: string;
challengeType: "http-webroot" | "dns-cloudflare" | "manual";
cloudflareToken: string;
lastIssuedAt: string | null;
certStatus: "none" | "valid" | "expiring" | "expired";
certExpiresAt: string | null;
};
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
if (!userId) return null;
const user = await createCurrentUserRepository().findById(userId);
return user?.isAdmin ? user : null;
}
function getCertInfo(): {
status: "none" | "valid" | "expiring" | "expired";
expiresAt: string | null;
} {
const certFile = path.join(SSL_DIR, "termix.crt");
try {
execFileSync("openssl", ["x509", "-in", certFile, "-noout"], {
stdio: "pipe",
});
} catch {
return { status: "none", expiresAt: null };
}
try {
const endDateRaw = execFileSync(
"openssl",
["x509", "-in", certFile, "-noout", "-enddate"],
{ stdio: "pipe" },
)
.toString()
.trim()
.replace("notAfter=", "");
const expiresAt = new Date(endDateRaw).toISOString();
try {
execFileSync(
"openssl",
["x509", "-in", certFile, "-checkend", "0", "-noout"],
{
stdio: "pipe",
},
);
} catch {
return { status: "expired", expiresAt };
}
try {
execFileSync(
"openssl",
["x509", "-in", certFile, "-checkend", "2592000", "-noout"],
{
stdio: "pipe",
},
);
return { status: "valid", expiresAt };
} catch {
return { status: "expiring", expiresAt };
}
} catch {
return { status: "none", expiresAt: null };
}
}
async function getAcmeSettings(): Promise<AcmeSettings> {
const { status, expiresAt } = getCertInfo();
const value =
await createCurrentSettingsRepository().get("acme_ssl_settings");
const stored = value ? JSON.parse(value) : {};
return {
enabled: stored.enabled ?? false,
domain: stored.domain ?? "",
email: stored.email ?? "",
challengeType: stored.challengeType ?? "http-webroot",
cloudflareToken: stored.cloudflareToken
? `${stored.cloudflareToken.slice(0, 4)}${"*".repeat(Math.max(0, stored.cloudflareToken.length - 4))}`
: "",
lastIssuedAt: stored.lastIssuedAt ?? null,
certStatus: status,
certExpiresAt: expiresAt,
};
}
export function registerAcmeSSLRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/acme-ssl-settings:
* get:
* summary: Get ACME SSL settings
* description: Returns current ACME/Let's Encrypt configuration and certificate status.
* tags:
* - Users
* responses:
* 200:
* description: ACME SSL settings and certificate status.
* 500:
* description: Failed to get ACME SSL settings.
*/
router.get("/acme-ssl-settings", authenticateJWT, async (_req, res) => {
try {
res.json(await getAcmeSettings());
} catch (err) {
authLogger.error("Failed to get ACME SSL settings", err);
res.status(500).json({ error: "Failed to get ACME SSL settings" });
}
});
/**
* @openapi
* /users/acme-ssl-settings:
* patch:
* summary: Update ACME SSL settings (admin only)
* description: Saves ACME/Let's Encrypt configuration.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* enabled:
* type: boolean
* domain:
* type: string
* email:
* type: string
* challengeType:
* type: string
* enum: [http-webroot, dns-cloudflare, manual]
* cloudflareToken:
* type: string
* responses:
* 200:
* description: ACME SSL settings updated.
* 403:
* description: Not authorized.
* 500:
* description: Failed to update ACME SSL settings.
*/
router.patch("/acme-ssl-settings", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
try {
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const settingsRepository = createCurrentSettingsRepository();
const existing = await settingsRepository.get("acme_ssl_settings");
const current = existing ? JSON.parse(existing) : {};
const { enabled, domain, email, challengeType, cloudflareToken } =
req.body;
const updated = {
...current,
...(typeof enabled === "boolean" && { enabled }),
...(typeof domain === "string" && { domain }),
...(typeof email === "string" && { email }),
...(typeof challengeType === "string" && { challengeType }),
...(typeof cloudflareToken === "string" &&
cloudflareToken &&
!cloudflareToken.includes("*") && { cloudflareToken }),
};
await settingsRepository.set(
"acme_ssl_settings",
JSON.stringify(updated),
);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "update_acme_ssl_settings",
resourceType: "setting",
details: JSON.stringify({
enabled,
domain,
email,
challengeType,
hasCloudflareToken: !!updated.cloudflareToken,
}),
ipAddress,
userAgent,
success: true,
});
res.json(await getAcmeSettings());
} catch (err) {
authLogger.error("Failed to update ACME SSL settings", err);
res.status(500).json({ error: "Failed to update ACME SSL settings" });
}
});
/**
* @openapi
* /users/acme-ssl-request:
* post:
* summary: Request or renew Let's Encrypt certificate (admin only)
* description: Triggers certbot to issue or renew a certificate using the configured challenge method.
* tags:
* - Users
* responses:
* 200:
* description: Certificate issued or renewed successfully.
* 400:
* description: Invalid configuration.
* 403:
* description: Not authorized.
* 500:
* description: Certificate issuance failed.
*/
router.post("/acme-ssl-request", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const actor = await getAdminActor(userId);
try {
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const settingsValue =
await createCurrentSettingsRepository().get("acme_ssl_settings");
if (!settingsValue) {
return res.status(400).json({ error: "ACME settings not configured" });
}
const settings = JSON.parse(settingsValue);
const { domain, email, challengeType, cloudflareToken } = settings;
if (!domain || !email) {
return res.status(400).json({ error: "Domain and email are required" });
}
try {
execFileSync("certbot", ["--version"], { stdio: "pipe" });
} catch {
return res
.status(500)
.json({ error: "certbot is not available in this environment" });
}
await fs.mkdir(SSL_DIR, { recursive: true });
await fs.mkdir(ACME_WEBROOT, { recursive: true });
await fs.mkdir(CERTBOT_CONFIG_DIR, { recursive: true });
await fs.mkdir(CERTBOT_WORK_DIR, { recursive: true });
await fs.mkdir(CERTBOT_LOGS_DIR, { recursive: true });
const certbotDirArgs = [
"--config-dir",
CERTBOT_CONFIG_DIR,
"--work-dir",
CERTBOT_WORK_DIR,
"--logs-dir",
CERTBOT_LOGS_DIR,
];
let certbotArgs: string[];
if (challengeType === "dns-cloudflare") {
if (!cloudflareToken) {
return res.status(400).json({
error: "Cloudflare API token is required for DNS challenge",
});
}
await fs.mkdir(path.dirname(CLOUDFLARE_CREDENTIALS_FILE), {
recursive: true,
});
await fs.writeFile(
CLOUDFLARE_CREDENTIALS_FILE,
`dns_cloudflare_api_token = ${cloudflareToken}\n`,
{ mode: 0o600 },
);
certbotArgs = [
"certonly",
"--non-interactive",
"--agree-tos",
"--dns-cloudflare",
"--dns-cloudflare-credentials",
CLOUDFLARE_CREDENTIALS_FILE,
"--dns-cloudflare-propagation-seconds",
"30",
"-d",
domain,
"--email",
email,
"--cert-name",
"termix",
...certbotDirArgs,
];
} else {
certbotArgs = [
"certonly",
"--non-interactive",
"--agree-tos",
"--webroot",
"-w",
ACME_WEBROOT,
"-d",
domain,
"--email",
email,
"--cert-name",
"termix",
...certbotDirArgs,
];
}
authLogger.info("Requesting Let's Encrypt certificate", {
domain,
challengeType,
operation: "acme_cert_request",
});
execFileSync("certbot", certbotArgs, {
stdio: "pipe",
timeout: 120000,
});
const liveDir = path.join(CERTBOT_CONFIG_DIR, "live", "termix");
const fullchainSrc = path.join(liveDir, "fullchain.pem");
const privkeySrc = path.join(liveDir, "privkey.pem");
const certDest = path.join(SSL_DIR, "termix.crt");
const keyDest = path.join(SSL_DIR, "termix.key");
await fs.copyFile(fullchainSrc, certDest);
await fs.copyFile(privkeySrc, keyDest);
await fs.chmod(keyDest, 0o600);
await fs.chmod(certDest, 0o644);
const updated = { ...settings, lastIssuedAt: new Date().toISOString() };
await createCurrentSettingsRepository().set(
"acme_ssl_settings",
JSON.stringify(updated),
);
authLogger.info("Let's Encrypt certificate issued and installed", {
domain,
operation: "acme_cert_installed",
});
const reload = reloadNginxWithSSL();
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "acme_ssl_request",
resourceType: "setting",
details: JSON.stringify({ domain, challengeType, success: true }),
ipAddress,
userAgent,
success: true,
});
res.json({
success: true,
reloadMessage: reload.message,
...(await getAcmeSettings()),
});
} catch (err) {
const message = getErrorMessage(err);
authLogger.error("ACME certificate request failed", err);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor?.username ?? userId,
action: "acme_ssl_request",
resourceType: "setting",
details: JSON.stringify({ error: message }),
ipAddress,
userAgent,
success: false,
});
res.status(500).json({ error: `Certificate request failed: ${message}` });
}
});
/**
* @openapi
* /users/manual-ssl-upload:
* post:
* summary: Upload a manual/custom SSL certificate and key (admin only)
* description: Validates and installs a user-supplied PEM certificate and private key as the active Termix SSL certificate.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* certificate:
* type: string
* privateKey:
* type: string
* responses:
* 200:
* description: Certificate uploaded and installed successfully.
* 400:
* description: Invalid or missing certificate/key.
* 403:
* description: Not authorized.
* 500:
* description: Certificate installation failed.
*/
router.post("/manual-ssl-upload", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const actor = await getAdminActor(userId);
try {
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { certificate, privateKey } = req.body;
if (
typeof certificate !== "string" ||
typeof privateKey !== "string" ||
!certificate.includes("BEGIN CERTIFICATE") ||
!privateKey.includes("PRIVATE KEY")
) {
return res.status(400).json({
error: "A valid PEM certificate and private key are required",
});
}
await fs.mkdir(SSL_DIR, { recursive: true });
const tmpCertFile = path.join(SSL_DIR, ".manual-upload.crt.tmp");
const tmpKeyFile = path.join(SSL_DIR, ".manual-upload.key.tmp");
try {
await fs.writeFile(tmpCertFile, certificate, { mode: 0o644 });
await fs.writeFile(tmpKeyFile, privateKey, { mode: 0o600 });
try {
execFileSync("openssl", ["x509", "-in", tmpCertFile, "-noout"], {
stdio: "pipe",
});
execFileSync(
"openssl",
["pkey", "-in", tmpKeyFile, "-noout", "-check"],
{ stdio: "pipe" },
);
} catch {
return res.status(400).json({
error:
"The provided certificate or private key is not valid PEM data",
});
}
const certPubkey = execFileSync(
"openssl",
["x509", "-in", tmpCertFile, "-noout", "-pubkey"],
{ stdio: "pipe" },
);
const keyPubkey = execFileSync(
"openssl",
["pkey", "-in", tmpKeyFile, "-pubout"],
{ stdio: "pipe" },
);
if (!certPubkey.equals(keyPubkey)) {
return res
.status(400)
.json({ error: "The certificate and private key do not match" });
}
const certDest = path.join(SSL_DIR, "termix.crt");
const keyDest = path.join(SSL_DIR, "termix.key");
await fs.rename(tmpCertFile, certDest);
await fs.rename(tmpKeyFile, keyDest);
await fs.chmod(keyDest, 0o600);
await fs.chmod(certDest, 0o644);
} finally {
await fs.rm(tmpCertFile, { force: true });
await fs.rm(tmpKeyFile, { force: true });
}
const settingsRepository = createCurrentSettingsRepository();
const existing = await settingsRepository.get("acme_ssl_settings");
const current = existing ? JSON.parse(existing) : {};
const updated = {
...current,
challengeType: "manual",
lastIssuedAt: new Date().toISOString(),
};
await settingsRepository.set(
"acme_ssl_settings",
JSON.stringify(updated),
);
authLogger.info("Manual SSL certificate installed", {
operation: "manual_ssl_installed",
});
const reload = reloadNginxWithSSL();
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor.username ?? userId,
action: "manual_ssl_upload",
resourceType: "setting",
details: JSON.stringify({ success: true }),
ipAddress,
userAgent,
success: true,
});
res.json({
success: true,
reloadMessage: reload.message,
...(await getAcmeSettings()),
});
} catch (err) {
const message = getErrorMessage(err);
authLogger.error("Manual SSL certificate upload failed", err);
const { ipAddress, userAgent } = getRequestMeta(req);
await logAudit({
userId,
username: actor?.username ?? userId,
action: "manual_ssl_upload",
resourceType: "setting",
details: JSON.stringify({ error: message }),
ipAddress,
userAgent,
success: false,
});
res
.status(500)
.json({ error: `Certificate installation failed: ${message}` });
}
});
}
+128
View File
@@ -0,0 +1,128 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { AuthenticatedRequest } from "../../../types/index.js";
import type { RequestHandler, Router } from "express";
import { authLogger } from "../../utils/logger.js";
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
import { createCurrentUserRepository } from "../repositories/factory.js";
import type { UserRecord } from "../repositories/user-repository.js";
import { TlsValidationError } from "../../tls/certificate.js";
import {
getTlsStatus,
reloadTls,
writeTlsCertificate,
} from "../../tls/tls-service.js";
async function getAdminActor(
userId: string | undefined,
): Promise<UserRecord | null> {
if (!userId) return null;
const user = await createCurrentUserRepository().findById(userId);
return user?.isAdmin ? user : null;
}
export function registerTlsRoutes(
router: Router,
authenticateJWT: RequestHandler,
): void {
/**
* @openapi
* /users/tls-certificate:
* get:
* summary: Get the served TLS certificate (admin only)
* description: Returns whether HTTPS is on, the certificate core serves, and the plugin that renews it, if any.
* tags:
* - Users
* responses:
* 200:
* description: Certificate status.
* 403:
* description: Not authorized.
* 500:
* description: Failed to read the certificate.
*/
router.get("/tls-certificate", authenticateJWT, async (req, res) => {
try {
const actor = await getAdminActor((req as AuthenticatedRequest).userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
res.json(await getTlsStatus());
} catch (err) {
authLogger.error("Failed to read TLS certificate status", err);
res.status(500).json({ error: "Failed to read TLS certificate status" });
}
});
/**
* @openapi
* /users/tls-certificate:
* post:
* summary: Upload a TLS certificate and key (admin only)
* description: Validates a PEM certificate chain and private key, installs them as the served certificate and reloads HTTPS without a restart.
* tags:
* - Users
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* certificate:
* type: string
* privateKey:
* type: string
* responses:
* 200:
* description: Certificate installed. Carries the new status and a reload message.
* 400:
* description: Missing, unreadable, expired or mismatched certificate and key.
* 403:
* description: Not authorized.
* 500:
* description: Certificate installation failed.
*/
router.post("/tls-certificate", authenticateJWT, async (req, res) => {
const userId = (req as AuthenticatedRequest).userId;
const actor = await getAdminActor(userId);
if (!actor) {
return res.status(403).json({ error: "Not authorized" });
}
const { ipAddress, userAgent } = getRequestMeta(req);
const audit = (success: boolean, details: object, errorMessage?: string) =>
logAudit({
userId,
username: actor.username ?? userId,
action: "tls_certificate_upload",
resourceType: "setting",
details: JSON.stringify(details),
ipAddress,
userAgent,
success,
errorMessage,
});
try {
const { certificate, privateKey } = req.body ?? {};
const info = await writeTlsCertificate(certificate, privateKey);
const reload = await reloadTls();
await audit(true, { subject: info.subject, notAfter: info.notAfter });
res.json({
success: true,
reloadMessage: reload.message,
...(await getTlsStatus()),
});
} catch (err) {
const message = getErrorMessage(err);
await audit(false, {}, message);
if (err instanceof TlsValidationError) {
return res.status(400).json({ error: message });
}
authLogger.error("TLS certificate upload failed", err);
res
.status(500)
.json({ error: `Certificate installation failed: ${message}` });
}
});
}
+2 -2
View File
@@ -18,7 +18,7 @@ import { PermissionManager } from "../../utils/permission-manager.js";
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
import { registerBrandingRoutes } from "./branding-routes.js";
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
import { registerAcmeSSLRoutes } from "./acme-ssl-routes.js";
import { registerTlsRoutes } from "./tls-routes.js";
import { registerUserSessionRoutes } from "./user-session-routes.js";
import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
@@ -1648,7 +1648,7 @@ registerUserOidcAccountRoutes(router, {
});
registerUserSettingsRoutes(router, authenticateJWT);
registerAcmeSSLRoutes(router, authenticateJWT);
registerTlsRoutes(router, authenticateJWT);
registerUserApiKeyRoutes(router, requireAdmin);
registerBrandingRoutes(router, requireAdmin);
+129
View File
@@ -0,0 +1,129 @@
/**
* ctx.system: the server certificate. Core serves it; a plugin under
* system:tls may replace it, reload it, answer ACME http-01 challenges and
* say that it renews it. Every call is audited.
*/
import type { PluginSystem } from "@termix/plugin-sdk/backend";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
const CAPABILITY = "system:tls";
async function audited<T>(
deps: Deps,
action: string,
details: string,
fn: () => Promise<T>,
): Promise<T> {
try {
await assertCapability(
deps.manifest.id,
CAPABILITY,
deps.manifest.capabilities,
);
const result = await fn();
await deps.audit(action, details, { success: true });
return result;
} catch (error) {
await deps.audit(action, details, {
success: false,
errorMessage: error instanceof Error ? error.message : String(error),
});
throw error;
}
}
/**
* Puts an undo in the plugin's bag and hands back one that also takes it out
* again, so it runs once whether the plugin or deactivate gets there first.
*/
function tracked(deps: Deps, undo: () => void, label: string): () => void {
let done = false;
const run = () => {
if (done) return;
done = true;
undo();
};
const untrack = deps.bag.add(run, label);
return () => {
untrack();
run();
};
}
export function createPluginSystem(deps: Deps): PluginSystem {
return {
tlsStatus: () =>
audited(deps, "tls_status", "read certificate status", async () => {
const { getTlsStatus } = await import("../tls/tls-service.js");
return getTlsStatus();
}),
writeTlsCertificate: (certificatePem, privateKeyPem) =>
audited(
deps,
"tls_write",
"replaced the server certificate",
async () => {
const { writeTlsCertificate } = await import("../tls/tls-service.js");
return writeTlsCertificate(certificatePem, privateKeyPem);
},
),
reloadTls: () =>
audited(
deps,
"tls_reload",
"reloaded the server certificate",
async () => {
const { reloadTls } = await import("../tls/tls-service.js");
return reloadTls();
},
),
publishHttpChallenge: (token, content) =>
audited(
deps,
"tls_challenge",
"published an ACME challenge",
async () => {
const { publishChallenge } =
await import("../tls/acme-challenges.js");
return tracked(
deps,
publishChallenge(token, content),
"acme challenge",
);
},
),
registerTlsRenewer: () =>
audited(
deps,
"tls_renewer",
"renews the server certificate",
async () => {
const { registerTlsRenewer } = await import("../tls/tls-service.js");
const undo = registerTlsRenewer({
pluginId: deps.manifest.id,
pluginName: deps.manifest.name,
});
return tracked(deps, undo, "tls renewer");
},
),
};
}
+2
View File
@@ -57,6 +57,7 @@ import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js";
import { createPluginFetch, createPluginNotify } from "./ctx-notify.js";
import { createPluginProcess } from "./ctx-process.js";
import { createPluginSystem } from "./ctx-system.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -828,6 +829,7 @@ export function createPluginContext(
audit: auditCall,
}),
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }),
desktop: {
openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request),
+5 -1
View File
@@ -3,7 +3,7 @@ import dotenv from "dotenv";
import { promises as fs, readFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { AutoSSLSetup } from "./utils/auto-ssl-setup.js";
import { AutoSSLSetup } from "./tls/self-signed.js";
import { AuthManager } from "./utils/auth-manager.js";
import { DataCrypto } from "./utils/data-crypto.js";
import { ensureDatabaseLayerPreupgradeBackup } from "./utils/database-layer-preupgrade-backup.js";
@@ -381,6 +381,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
await import("./utils/crypto-migration/step-ca-settings-migration.js");
await runStepCaSettingsMigration();
const { runAcmeSslSettingsMigration } =
await import("./utils/crypto-migration/acme-ssl-settings-migration.js");
await runAcmeSslSettingsMigration();
const { runVaultSettingsMigration } =
await import("./utils/crypto-migration/vault-settings-migration.js");
await runVaultSettingsMigration();
@@ -0,0 +1,155 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import express, { type RequestHandler } from "express";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
const state = vi.hoisted(() => ({
admins: new Set<string>(["admin-1"]),
writes: [] as Array<[unknown, unknown]>,
reloads: 0,
audits: [] as Array<{ action: string; success: boolean }>,
}));
vi.mock("../../../utils/logger.js", () => ({
authLogger: { warn: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("../../../utils/audit-logger.js", () => ({
logAudit: async (entry: { action: string; success: boolean }) => {
state.audits.push({ action: entry.action, success: entry.success });
},
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
vi.mock("../../../database/repositories/factory.js", () => ({
createCurrentUserRepository: () => ({
findById: async (id: string) => ({
id,
username: id,
isAdmin: state.admins.has(id),
}),
}),
}));
vi.mock("../../../tls/tls-service.js", async () => {
const { TlsValidationError } = await import("../../../tls/certificate.js");
return {
getTlsStatus: async () => ({
enabled: true,
certificate: null,
renewal: null,
}),
writeTlsCertificate: async (cert: unknown, key: unknown) => {
if (key === "mismatch") {
throw new TlsValidationError(
"The certificate and private key do not match",
);
}
state.writes.push([cert, key]);
return { subject: "CN=x", notAfter: "2030-01-01T00:00:00.000Z" };
},
reloadTls: async () => {
state.reloads++;
return { applied: true, message: "reloaded" };
},
};
});
const { registerTlsRoutes } =
await import("../../../database/routes/tls-routes.js");
const authenticate: RequestHandler = (req, _res, next) => {
(req as unknown as { userId?: string }).userId =
req.header("x-user") ?? undefined;
next();
};
let server: http.Server;
let base: string;
beforeAll(async () => {
const router = express.Router();
registerTlsRoutes(router, authenticate);
const app = express();
app.use(express.json());
app.use("/users", router);
server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
state.writes = [];
state.reloads = 0;
state.audits = [];
});
const upload = (user: string, body: unknown) =>
fetch(`${base}/users/tls-certificate`, {
method: "POST",
headers: { "content-type": "application/json", "x-user": user },
body: JSON.stringify(body),
});
describe("TLS certificate routes", () => {
it("returns the status to an admin only", async () => {
const ok = await fetch(`${base}/users/tls-certificate`, {
headers: { "x-user": "admin-1" },
});
expect(ok.status).toBe(200);
expect(await ok.json()).toMatchObject({ enabled: true, renewal: null });
const denied = await fetch(`${base}/users/tls-certificate`, {
headers: { "x-user": "user-1" },
});
expect(denied.status).toBe(403);
});
it("installs an uploaded pair and reloads", async () => {
const response = await upload("admin-1", {
certificate: "CERT",
privateKey: "KEY",
});
expect(response.status).toBe(200);
expect((await response.json()).reloadMessage).toBe("reloaded");
expect(state.writes).toEqual([["CERT", "KEY"]]);
expect(state.reloads).toBe(1);
expect(state.audits).toEqual([
{ action: "tls_certificate_upload", success: true },
]);
});
it("answers 400 for a mismatched pair without reloading", async () => {
const response = await upload("admin-1", {
certificate: "CERT",
privateKey: "mismatch",
});
expect(response.status).toBe(400);
expect((await response.json()).error).toMatch(/do not match/);
expect(state.reloads).toBe(0);
expect(state.audits).toEqual([
{ action: "tls_certificate_upload", success: false },
]);
});
it("refuses a non-admin upload", async () => {
const response = await upload("user-1", {
certificate: "CERT",
privateKey: "KEY",
});
expect(response.status).toBe(403);
expect(state.writes).toEqual([]);
});
});
@@ -0,0 +1,142 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
granted: new Set<string>(),
writes: [] as Array<[string, string]>,
reloads: 0,
renewers: [] as Array<{ pluginId: string; pluginName: string }>,
challenges: new Map<string, string>(),
}));
vi.mock("../../plugins/permissions.js", async () => {
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
return {
assertCapability: async (
pluginId: string,
capability: string,
declared: readonly string[],
) => {
if (!declared.includes(capability) || !h.granted.has(capability)) {
throw new PluginCapabilityError(pluginId, capability);
}
},
};
});
vi.mock("../../tls/tls-service.js", () => ({
getTlsStatus: async () => ({
enabled: true,
certificate: null,
renewal: h.renewers[0] ?? null,
}),
writeTlsCertificate: async (cert: string, key: string) => {
if (key === "wrong")
throw new Error("The certificate and private key do not match");
h.writes.push([cert, key]);
return { subject: "CN=x" };
},
reloadTls: async () => {
h.reloads++;
return { applied: true, message: "ok" };
},
registerTlsRenewer: (renewer: { pluginId: string; pluginName: string }) => {
h.renewers.push(renewer);
return () => {
h.renewers = h.renewers.filter((entry) => entry !== renewer);
};
},
}));
vi.mock("../../tls/acme-challenges.js", () => ({
publishChallenge: (token: string, content: string) => {
h.challenges.set(token, content);
return () => h.challenges.delete(token);
},
}));
const { createPluginSystem } = await import("../../plugins/ctx-system.js");
const { DisposableBag } = await import("../../plugins/disposables.js");
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
let audits: Array<{ action: string; success: boolean }>;
function create(capabilities = ["system:tls"]) {
const bag = new DisposableBag("acme-ssl");
const system = createPluginSystem({
manifest: {
id: "acme-ssl",
name: "ACME Certificates",
version: "1.0.0",
capabilities,
} as never,
bag,
audit: async (action, _details, outcome) => {
audits.push({ action, success: outcome.success });
},
});
return { system, bag };
}
beforeEach(() => {
h.granted = new Set(["system:tls"]);
h.writes = [];
h.reloads = 0;
h.renewers = [];
h.challenges = new Map();
audits = [];
});
describe("ctx.system", () => {
it("writes, reloads and audits every call", async () => {
const { system } = create();
await system.writeTlsCertificate("CERT", "KEY");
await system.reloadTls();
expect(h.writes).toEqual([["CERT", "KEY"]]);
expect(h.reloads).toBe(1);
expect(audits).toEqual([
{ action: "tls_write", success: true },
{ action: "tls_reload", success: true },
]);
});
it("passes a rejected pair back to the plugin and audits the failure", async () => {
const { system } = create();
await expect(system.writeTlsCertificate("CERT", "wrong")).rejects.toThrow(
/do not match/,
);
expect(audits).toEqual([{ action: "tls_write", success: false }]);
});
it("refuses without system:tls, declared or granted", async () => {
await expect(create([]).system.reloadTls()).rejects.toBeInstanceOf(
PluginCapabilityError,
);
h.granted.clear();
await expect(create().system.tlsStatus()).rejects.toBeInstanceOf(
PluginCapabilityError,
);
expect(h.reloads).toBe(0);
expect(audits.every((entry) => !entry.success)).toBe(true);
});
it("drops the renewer and challenges when the plugin is disabled", async () => {
const { system, bag } = create();
await system.registerTlsRenewer();
await system.publishHttpChallenge("tok", "tok.key");
expect((await system.tlsStatus()).renewal?.pluginId).toBe("acme-ssl");
expect(h.challenges.get("tok")).toBe("tok.key");
await bag.disposeAll();
expect(h.renewers).toEqual([]);
expect(h.challenges.size).toBe(0);
});
it("lets the plugin withdraw a challenge itself, once", async () => {
const { system, bag } = create();
const remove = await system.publishHttpChallenge("tok", "tok.key");
remove();
remove();
expect(h.challenges.size).toBe(0);
expect(bag.size).toBe(0);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
TlsValidationError,
readCertificateInfo,
validateCertificatePair,
} from "../../tls/certificate.js";
import { caIssuedPair, selfSignedPair } from "./test-certs.js";
describe("validateCertificatePair", () => {
it("accepts a matching pair and describes the leaf", async () => {
const pair = await caIssuedPair("termix.example.com");
const info = validateCertificatePair(pair.certificate, pair.privateKey);
expect(info.names).toEqual(["termix.example.com"]);
expect(info.selfSigned).toBe(false);
expect(info.issuer).toContain("Test CA");
expect(new Date(info.notAfter).getTime()).toBeGreaterThan(Date.now());
});
it("rejects a key that belongs to another certificate", async () => {
const one = await caIssuedPair();
const other = await caIssuedPair();
expect(() =>
validateCertificatePair(one.certificate, other.privateKey),
).toThrow(/do not match/);
});
it("rejects garbage and an expired certificate", async () => {
const pair = await caIssuedPair();
expect(() => validateCertificatePair("nope", pair.privateKey)).toThrow(
TlsValidationError,
);
expect(() =>
validateCertificatePair(pair.certificate, "not a key"),
).toThrow(/private key/);
const expired = await caIssuedPair("old.example", { daysLeft: -1 });
expect(() =>
validateCertificatePair(expired.certificate, expired.privateKey),
).toThrow(/expired/);
});
it("reads the leaf of a full chain", async () => {
const leaf = await caIssuedPair("chain.example");
const ca = await selfSignedPair("ca.example");
const info = validateCertificatePair(
leaf.certificate + ca.certificate,
leaf.privateKey,
);
expect(info.names).toEqual(["chain.example"]);
});
});
describe("readCertificateInfo", () => {
it("marks core's own certificate as self-signed", async () => {
const pair = await selfSignedPair("localhost");
expect(readCertificateInfo(pair.certificate)?.selfSigned).toBe(true);
expect(readCertificateInfo("")).toBeNull();
});
});
+82
View File
@@ -0,0 +1,82 @@
/** Throwaway certificates for the TLS tests, made in-process. */
import "reflect-metadata";
import { webcrypto } from "node:crypto";
import * as x509 from "@peculiar/x509";
x509.cryptoProvider.set(webcrypto as unknown as Crypto);
const ALG = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" };
const DAY = 86_400_000;
export interface TestPair {
certificate: string;
privateKey: string;
}
async function keys(): Promise<webcrypto.CryptoKeyPair> {
return (await webcrypto.subtle.generateKey(ALG, true, [
"sign",
"verify",
])) as webcrypto.CryptoKeyPair;
}
async function keyPem(key: webcrypto.CryptoKey): Promise<string> {
const der = Buffer.from(await webcrypto.subtle.exportKey("pkcs8", key));
const body = der
.toString("base64")
.match(/.{1,64}/g)!
.join("\n");
return `-----BEGIN PRIVATE KEY-----\n${body}\n-----END PRIVATE KEY-----\n`;
}
function sans(names: string[]) {
return new x509.SubjectAlternativeNameExtension(
names.map((value) => ({ type: "dns" as const, value })),
);
}
export async function selfSignedPair(
name = "localhost",
options: { daysLeft?: number } = {},
): Promise<TestPair> {
const pair = await keys();
const cert = await x509.X509CertificateGenerator.createSelfSigned({
serialNumber: "01",
name: `CN=${name}`,
notBefore: new Date(Date.now() - DAY),
notAfter: new Date(Date.now() + (options.daysLeft ?? 365) * DAY),
keys: pair,
signingAlgorithm: ALG,
extensions: [sans([name])],
});
return {
certificate: cert.toString("pem"),
privateKey: await keyPem(pair.privateKey),
};
}
/** A leaf signed by a throwaway CA, like one from Let's Encrypt. */
export async function caIssuedPair(
name = "termix.example.com",
options: { daysLeft?: number } = {},
): Promise<TestPair> {
const caKeys = await keys();
const leafKeys = await keys();
const cert = await x509.X509CertificateGenerator.create({
serialNumber: "02",
subject: `CN=${name}`,
issuer: "CN=Test CA",
notBefore: new Date(Date.now() - 2 * DAY),
notAfter: new Date(Date.now() + (options.daysLeft ?? 90) * DAY),
signingKey: caKeys.privateKey,
publicKey: leafKeys.publicKey,
signingAlgorithm: ALG,
extensions: [sans([name])],
});
return {
certificate: cert.toString("pem"),
privateKey: await keyPem(leafKeys.privateKey),
};
}
+199
View File
@@ -0,0 +1,199 @@
/**
* Core keeps serving HTTPS on its own: a certificate on disk is served with
* no plugin renewing it, a validated replacement is swapped in without a
* restart, and nginx is reloaded instead when it terminates TLS.
*/
import fs from "node:fs/promises";
import https from "node:https";
import os from "node:os";
import path from "node:path";
import tls from "node:tls";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { caIssuedPair, selfSignedPair } from "./test-certs.js";
const nginx = vi.hoisted(() => ({
reload: vi.fn(() => ({ applied: true, message: "nginx reloaded" })),
}));
vi.mock("../../utils/logger.js", () => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
};
return { systemLogger: logger, authLogger: logger };
});
vi.mock("../../tls/nginx-reload.js", () => ({
reloadNginxWithSSL: nginx.reload,
persistSSLEnv: vi.fn(),
}));
const service = await import("../../tls/tls-service.js");
const challenges = await import("../../tls/acme-challenges.js");
const { X509Certificate } = await import("node:crypto");
let dir: string;
let servers: https.Server[] = [];
const savedEnv = { ...process.env };
async function install(pair: { certificate: string; privateKey: string }) {
await fs.mkdir(path.join(dir, "ssl"), { recursive: true });
await fs.writeFile(path.join(dir, "ssl", "termix.crt"), pair.certificate);
await fs.writeFile(path.join(dir, "ssl", "termix.key"), pair.privateKey);
}
function fingerprint(pem: string): string {
return new X509Certificate(pem).fingerprint256;
}
async function startServer(): Promise<number> {
let resolvePort: (port: number) => void = () => {};
const port = new Promise<number>((resolve) => (resolvePort = resolve));
await service.configureDirectHttps((options) => {
const server = https.createServer(options, (_req, res) => res.end("ok"));
servers.push(server);
server.listen(0, "127.0.0.1", () =>
resolvePort((server.address() as AddressInfo).port),
);
return server;
});
return port;
}
function servedFingerprint(port: number): Promise<string> {
return new Promise((resolve, reject) => {
const socket = tls.connect(
{ host: "127.0.0.1", port, rejectUnauthorized: false },
() => {
const cert = socket.getPeerCertificate();
socket.end();
resolve(cert.fingerprint256);
},
);
socket.on("error", reject);
});
}
beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), "tls-service-"));
process.env.DATA_DIR = dir;
process.env.ENABLE_SSL = "true";
delete process.env.SSL_CERT_PATH;
delete process.env.SSL_KEY_PATH;
delete process.env.TERMIX_SSL_TERMINATED_BY_NGINX;
service.resetTlsServiceForTests();
challenges.clearChallengesForTests();
nginx.reload.mockClear();
});
afterEach(async () => {
await Promise.all(
servers.map(
(server) => new Promise<void>((resolve) => server.close(() => resolve())),
),
);
servers = [];
process.env = { ...savedEnv };
await fs.rm(dir, { recursive: true, force: true });
});
describe("serving with no renewer", () => {
it("serves the certificate on disk when no plugin renews it", async () => {
const pair = await caIssuedPair("termix.example.com");
await install(pair);
const port = await startServer();
expect(await servedFingerprint(port)).toBe(fingerprint(pair.certificate));
const status = await service.getTlsStatus();
expect(status.renewal).toBeNull();
expect(status.certificate?.names).toEqual(["termix.example.com"]);
expect(status.certificate?.selfSigned).toBe(false);
});
it("reports the plugin that renews it until it goes away", async () => {
const undo = service.registerTlsRenewer({
pluginId: "acme-ssl",
pluginName: "ACME Certificates",
});
expect((await service.getTlsStatus()).renewal?.pluginId).toBe("acme-ssl");
undo();
undo();
expect((await service.getTlsStatus()).renewal).toBeNull();
});
});
describe("writeTlsCertificate and reloadTls", () => {
it("swaps a new certificate in without a restart", async () => {
await install(await selfSignedPair("localhost"));
const port = await startServer();
const next = await caIssuedPair("termix.example.com");
await service.writeTlsCertificate(next.certificate, next.privateKey);
const result = await service.reloadTls();
expect(result.applied).toBe(true);
expect(await servedFingerprint(port)).toBe(fingerprint(next.certificate));
expect(nginx.reload).not.toHaveBeenCalled();
});
it("refuses a mismatched key and leaves the files alone", async () => {
const current = await caIssuedPair("keep.example");
await install(current);
const one = await caIssuedPair("new.example");
const other = await caIssuedPair("new.example");
await expect(
service.writeTlsCertificate(one.certificate, other.privateKey),
).rejects.toThrow(/do not match/);
expect(await fs.readFile(path.join(dir, "ssl", "termix.crt"), "utf8")).toBe(
current.certificate,
);
});
it("reloads nginx when nginx terminates TLS", async () => {
process.env.TERMIX_SSL_TERMINATED_BY_NGINX = "true";
const pair = await caIssuedPair();
await service.writeTlsCertificate(pair.certificate, pair.privateKey);
const result = await service.reloadTls();
expect(nginx.reload).toHaveBeenCalledOnce();
expect(result.message).toBe("nginx reloaded");
});
});
describe("ACME http-01 challenges", () => {
it("answers a published token until it is withdrawn", () => {
const undo = challenges.publishChallenge("abc_DEF-1", "abc_DEF-1.thumb");
expect(challenges.lookupChallenge("abc_DEF-1")).toBe("abc_DEF-1.thumb");
const res = {
statusCode: 0,
body: "",
status(code: number) {
this.statusCode = code;
return this;
},
type() {
return this;
},
send(body: string) {
this.body = body;
return this;
},
};
challenges.acmeChallengeHandler(
{ params: { token: "abc_DEF-1" } } as never,
res as never,
);
expect(res.statusCode).toBe(200);
expect(res.body).toBe("abc_DEF-1.thumb");
undo();
expect(challenges.lookupChallenge("abc_DEF-1")).toBeNull();
expect(() => challenges.publishChallenge("../etc", "x")).toThrow();
});
});
@@ -0,0 +1,144 @@
/**
* The ACME settings migration: a 2.8 acme_ssl_settings row comes through into
* the acme-ssl plugin's admin settings, the Cloudflare token stays encrypted
* and leaves the old row, and a second run changes nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
legacy: {} as Record<string, string>,
pluginRows: [] as Array<{
key: string;
value: string | null;
encrypted: boolean;
}>,
pluginInstalled: true,
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
state.pluginInstalled && id === "acme-ssl" ? { id } : null,
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => state.legacy[key] ?? null,
set: async (key: string, value: string) => {
state.legacy[key] = value;
},
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
) => state.pluginRows.find((row) => row.key === key) ?? null,
set: async (
_pluginId: string,
_scope: string,
_scopeId: string | null,
key: string,
value: string | null,
encrypted = false,
) => {
state.pluginRows = state.pluginRows.filter((row) => row.key !== key);
state.pluginRows.push({ key, value, encrypted });
},
}),
}));
vi.mock("../../utils/system-secret-crypto.js", () => ({
encryptSystemSecret: async (value: string) => `enc:${value}`,
}));
vi.mock("../../utils/logger.js", () => ({
databaseLogger: { info: vi.fn(), warn: vi.fn() },
}));
import { runAcmeSslSettingsMigration } from "../../utils/crypto-migration/acme-ssl-settings-migration.js";
const value = (key: string) => {
const row = state.pluginRows.find((candidate) => candidate.key === key);
return row?.value == null ? undefined : JSON.parse(row.value);
};
beforeEach(() => {
state.legacy = {
acme_ssl_settings: JSON.stringify({
enabled: true,
domain: "termix.example.com",
email: "admin@example.com",
challengeType: "dns-cloudflare",
cloudflareToken: "cf-token",
lastIssuedAt: "2026-01-01T00:00:00.000Z",
}),
};
state.pluginRows = [];
state.pluginInstalled = true;
});
describe("runAcmeSslSettingsMigration", () => {
it("moves every field and encrypts the token", async () => {
const result = await runAcmeSslSettingsMigration();
expect(result.moved.sort()).toEqual(
[
"autoRenew",
"challengeType",
"cloudflareToken",
"domain",
"email",
].sort(),
);
expect(value("domain")).toBe("termix.example.com");
expect(value("email")).toBe("admin@example.com");
expect(value("challengeType")).toBe("dns-cloudflare");
expect(value("autoRenew")).toBe(true);
expect(value("cloudflareToken")).toBe("enc:cf-token");
expect(
state.pluginRows.find((row) => row.key === "cloudflareToken")?.encrypted,
).toBe(true);
const left = JSON.parse(state.legacy.acme_ssl_settings);
expect(left.cloudflareToken).toBeUndefined();
expect(left.domain).toBe("termix.example.com");
});
it("maps the webroot challenge and turns renewal off for manual", async () => {
state.legacy.acme_ssl_settings = JSON.stringify({
enabled: true,
domain: "a.example",
email: "a@example.com",
challengeType: "http-webroot",
});
await runAcmeSslSettingsMigration();
expect(value("challengeType")).toBe("http-01");
expect(value("autoRenew")).toBe(true);
state.pluginRows = [];
state.legacy.acme_ssl_settings = JSON.stringify({
enabled: true,
challengeType: "manual",
});
await runAcmeSslSettingsMigration();
expect(value("autoRenew")).toBe(false);
expect(value("challengeType")).toBeUndefined();
});
it("changes nothing on a second run or over values already set", async () => {
await runAcmeSslSettingsMigration();
const snapshot = JSON.stringify(state.pluginRows);
const second = await runAcmeSslSettingsMigration();
expect(second.moved).toEqual([]);
expect(JSON.stringify(state.pluginRows)).toBe(snapshot);
});
it("does nothing before the plugin row exists or without a legacy row", async () => {
state.pluginInstalled = false;
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
state.pluginInstalled = true;
state.legacy = {};
expect((await runAcmeSslSettingsMigration()).moved).toEqual([]);
expect(state.pluginRows).toEqual([]);
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* ACME http-01 answers. The CA asks for /.well-known/acme-challenge/<token>
* on port 80 of the domain, which no plugin route can own, so core serves
* whatever a plugin published through ctx.system.publishHttpChallenge.
*/
import type { Request, Response } from "express";
const MAX_AGE_MS = 60 * 60 * 1000;
const TOKEN = /^[A-Za-z0-9_-]{1,256}$/;
const challenges = new Map<string, { content: string; expiresAt: number }>();
/** Returns the undo. A challenge also expires on its own after an hour. */
export function publishChallenge(token: string, content: string): () => void {
if (!TOKEN.test(token)) {
throw new Error("ACME challenge tokens are base64url");
}
if (typeof content !== "string" || content.length === 0) {
throw new Error("ACME challenge content must be a non-empty string");
}
const entry = { content, expiresAt: Date.now() + MAX_AGE_MS };
challenges.set(token, entry);
return () => {
if (challenges.get(token) === entry) challenges.delete(token);
};
}
export function lookupChallenge(token: string): string | null {
const entry = challenges.get(token);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
challenges.delete(token);
return null;
}
return entry.content;
}
export function clearChallengesForTests(): void {
challenges.clear();
}
/** GET /.well-known/acme-challenge/:token, public by design. */
export function acmeChallengeHandler(req: Request, res: Response): void {
const content = lookupChallenge(String(req.params.token ?? ""));
if (content === null) {
res.status(404).type("text/plain").send("Not found");
return;
}
res.status(200).type("text/plain").send(content);
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Reading and checking PEM certificates with node's own X509 parser, so none
* of this needs the openssl binary.
*/
import { X509Certificate, createPrivateKey } from "crypto";
export interface TlsCertificateInfo {
subject: string;
issuer: string;
names: string[];
notBefore: string;
notAfter: string;
selfSigned: boolean;
fingerprint: string;
}
export class TlsValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "TlsValidationError";
}
}
const PEM_CERT =
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g;
/** The leaf is the first certificate in a full chain. */
export function splitPemChain(pem: string): string[] {
return pem.match(PEM_CERT) ?? [];
}
function parseNames(subjectAltName: string | undefined): string[] {
if (!subjectAltName) return [];
return subjectAltName
.split(",")
.map((entry) => entry.trim())
.map((entry) => {
const match = /^(DNS|IP Address):(.+)$/.exec(entry);
return match ? match[2].trim() : null;
})
.filter((name): name is string => !!name);
}
function isSelfSigned(cert: X509Certificate): boolean {
if (cert.subject !== cert.issuer) return false;
try {
return cert.checkIssued(cert) && cert.verify(cert.publicKey);
} catch {
return false;
}
}
export function describeCertificate(cert: X509Certificate): TlsCertificateInfo {
return {
subject: cert.subject.replace(/\n/g, ", "),
issuer: cert.issuer.replace(/\n/g, ", "),
names: parseNames(cert.subjectAltName),
notBefore: new Date(cert.validFrom).toISOString(),
notAfter: new Date(cert.validTo).toISOString(),
selfSigned: isSelfSigned(cert),
fingerprint: cert.fingerprint256,
};
}
/** Parses the leaf of a PEM chain. Null when there is nothing readable. */
export function readCertificateInfo(pem: string): TlsCertificateInfo | null {
const [leaf] = splitPemChain(pem);
if (!leaf) return null;
try {
return describeCertificate(new X509Certificate(leaf));
} catch {
return null;
}
}
/**
* Throws TlsValidationError unless the chain parses, the leaf is still valid
* and the key belongs to it. Returns the leaf's details.
*/
export function validateCertificatePair(
certificatePem: string,
privateKeyPem: string,
now: Date = new Date(),
): TlsCertificateInfo {
if (typeof certificatePem !== "string" || typeof privateKeyPem !== "string") {
throw new TlsValidationError(
"A PEM certificate and private key are required",
);
}
const chain = splitPemChain(certificatePem);
if (chain.length === 0) {
throw new TlsValidationError("The certificate is not PEM data");
}
let leaf: X509Certificate;
try {
leaf = new X509Certificate(chain[0]);
for (const extra of chain.slice(1)) new X509Certificate(extra);
} catch {
throw new TlsValidationError("The certificate could not be parsed");
}
let key: ReturnType<typeof createPrivateKey>;
try {
key = createPrivateKey(privateKeyPem);
} catch {
throw new TlsValidationError("The private key could not be parsed");
}
if (!leaf.checkPrivateKey(key)) {
throw new TlsValidationError(
"The certificate and private key do not match",
);
}
if (new Date(leaf.validTo).getTime() <= now.getTime()) {
throw new TlsValidationError("The certificate has expired");
}
return describeCertificate(leaf);
}
@@ -1,7 +1,7 @@
import { execFileSync } from "child_process";
import { existsSync, readFileSync, writeFileSync } from "fs";
import path from "path";
import { authLogger } from "./logger.js";
import { authLogger } from "../utils/logger.js";
const NGINX_TEMPLATE = "/app/nginx/nginx-https.conf.template";
const NGINX_CONF = "/tmp/nginx/nginx.conf";
@@ -11,7 +11,11 @@ const DATA_DIR = process.env.DATA_DIR || "./db/data";
const SSL_DIR = path.join(DATA_DIR, "ssl");
const ENV_FILE = path.join(DATA_DIR, ".env");
function persistSSLEnv(sslPort: string, certPath: string, keyPath: string) {
export function persistSSLEnv(
sslPort: string,
certPath: string,
keyPath: string,
) {
const vars: Record<string, string> = {
ENABLE_SSL: "true",
SSL_PORT: sslPort,
@@ -1,8 +1,11 @@
import { getErrorMessage } from "./error-message.js";
import { getErrorMessage } from "../utils/error-message.js";
import { execSync } from "child_process";
import { promises as fs } from "fs";
import path from "path";
import { systemLogger } from "./logger.js";
import { systemLogger } from "../utils/logger.js";
import { readCertificateInfo } from "./certificate.js";
const RENEW_WINDOW_MS = 30 * 86_400_000;
export class AutoSSLSetup {
private static readonly DATA_DIR = process.env.DATA_DIR || "./db/data";
@@ -28,29 +31,12 @@ export class AutoSSLSetup {
}
try {
if (await this.isSSLConfigured()) {
await this.logCertificateInfo();
await this.setupEnvironmentVariables();
return;
}
try {
await fs.access(this.CERT_FILE);
await fs.access(this.KEY_FILE);
systemLogger.info("SSL certificates found from entrypoint script", {
operation: "ssl_cert_found_entrypoint",
cert_path: this.CERT_FILE,
key_path: this.KEY_FILE,
});
await this.logCertificateInfo();
await this.setupEnvironmentVariables();
return;
} catch {
if (await this.needsSelfSigned()) {
await this.generateSSLCertificates();
await this.setupEnvironmentVariables();
} else {
await this.logCertificateInfo();
}
await this.setupEnvironmentVariables();
} catch (error) {
systemLogger.error("Failed to initialize SSL configuration", error, {
operation: "ssl_auto_init_failed",
@@ -62,40 +48,44 @@ export class AutoSSLSetup {
}
}
private static async isSSLConfigured(): Promise<boolean> {
/**
* Only core's own certificate is ever replaced here. One a CA issued keeps
* being served until it expires, even when nothing renews it.
*/
private static async needsSelfSigned(): Promise<boolean> {
let pem: string;
try {
await fs.access(this.CERT_FILE);
await fs.access(this.KEY_FILE);
execSync(
`openssl x509 -in "${this.CERT_FILE}" -checkend 2592000 -noout`,
{
stdio: "pipe",
},
);
pem = await fs.readFile(this.CERT_FILE, "utf8");
} catch {
systemLogger.info("SSL certificate not found, will generate one", {
operation: "ssl_cert_missing",
cert_path: this.CERT_FILE,
});
return true;
} catch (error) {
if (error instanceof Error && error.message.includes("checkend")) {
systemLogger.warn(
"SSL certificate is expired or expiring soon, will regenerate",
{
operation: "ssl_cert_expired",
cert_path: this.CERT_FILE,
error: error.message,
},
);
} else {
systemLogger.info(
"SSL certificate not found or invalid, will generate new one",
{
operation: "ssl_cert_missing",
cert_path: this.CERT_FILE,
},
);
}
return false;
}
const info = readCertificateInfo(pem);
if (!info) {
systemLogger.warn("SSL certificate is unreadable, will generate one", {
operation: "ssl_cert_invalid",
cert_path: this.CERT_FILE,
});
return true;
}
if (
info.selfSigned &&
new Date(info.notAfter).getTime() - Date.now() < RENEW_WINDOW_MS
) {
systemLogger.warn(
"Self-signed SSL certificate is expiring, will regenerate",
{ operation: "ssl_cert_expired", cert_path: this.CERT_FILE },
);
return true;
}
return false;
}
private static async generateSSLCertificates(): Promise<void> {
@@ -210,7 +200,6 @@ IP.3 = 0.0.0.0
issuer: issuer.replace("issuer=", ""),
valid_from: notBefore.replace("notBefore=", ""),
valid_until: notAfter.replace("notAfter=", ""),
note: "Certificate will auto-renew 30 days before expiration",
});
} catch (error) {
systemLogger.warn("Could not retrieve certificate information", {
@@ -270,14 +259,4 @@ IP.3 = 0.0.0.0
process.env[key] = value;
}
}
static getSSLConfig() {
return {
enabled: process.env.ENABLE_SSL === "true",
port: parseInt(process.env.SSL_PORT || "8443"),
certPath: process.env.SSL_CERT_PATH || this.CERT_FILE,
keyPath: process.env.SSL_KEY_PATH || this.KEY_FILE,
domain: process.env.SSL_DOMAIN || "localhost",
};
}
}
+227
View File
@@ -0,0 +1,227 @@
/**
* Core's side of HTTPS: where the certificate lives, replacing it, and
* serving a new one without a restart. Getting a certificate (ACME) is a
* plugin's job; it reaches this through ctx.system.
*/
import { promises as fs } from "fs";
import https from "https";
import path from "path";
import { systemLogger } from "../utils/logger.js";
import {
readCertificateInfo,
validateCertificatePair,
type TlsCertificateInfo,
} from "./certificate.js";
import { persistSSLEnv, reloadNginxWithSSL } from "./nginx-reload.js";
export interface TlsConfig {
enabled: boolean;
port: number;
certPath: string;
keyPath: string;
domain: string;
/** In Docker nginx serves HTTPS and the backend only speaks HTTP. */
terminatedByNginx: boolean;
}
export interface TlsRenewer {
pluginId: string;
pluginName: string;
}
export interface TlsStatus {
enabled: boolean;
certificate: TlsCertificateInfo | null;
renewal: TlsRenewer | null;
}
export interface TlsReloadResult {
applied: boolean;
message: string;
}
export function getTlsConfig(env: NodeJS.ProcessEnv = process.env): TlsConfig {
const sslDir = path.join(env.DATA_DIR || "./db/data", "ssl");
return {
enabled: env.ENABLE_SSL === "true",
port: parseInt(env.SSL_PORT || "8443"),
certPath: env.SSL_CERT_PATH || path.join(sslDir, "termix.crt"),
keyPath: env.SSL_KEY_PATH || path.join(sslDir, "termix.key"),
domain: env.SSL_DOMAIN || "localhost",
terminatedByNginx: env.TERMIX_SSL_TERMINATED_BY_NGINX === "true",
};
}
type HttpsFactory = (options: https.ServerOptions) => https.Server;
let directServer: https.Server | null = null;
let directFactory: HttpsFactory | null = null;
/**
* Called once by the backend with how to build its direct HTTPS server. Starts
* it now when HTTPS is on and nginx is not the one serving it.
*/
export async function configureDirectHttps(
factory: HttpsFactory,
): Promise<https.Server | null> {
directFactory = factory;
const config = getTlsConfig();
if (!config.enabled || config.terminatedByNginx) return null;
return startDirectServer(config);
}
async function readPair(
config: TlsConfig,
): Promise<{ cert: Buffer; key: Buffer }> {
const [cert, key] = await Promise.all([
fs.readFile(config.certPath),
fs.readFile(config.keyPath),
]);
return { cert, key };
}
async function startDirectServer(
config: TlsConfig,
): Promise<https.Server | null> {
if (!directFactory) return null;
try {
directServer = directFactory(await readPair(config));
return directServer;
} catch (error) {
systemLogger.error(
"Failed to start HTTPS server with configured SSL certificate",
error,
{
operation: "https_server_start_failed",
cert_path: config.certPath,
key_path: config.keyPath,
},
);
return null;
}
}
/** Only for tests: forget the server and factory. */
export function resetTlsServiceForTests(): void {
directServer = null;
directFactory = null;
renewers.clear();
}
const renewers = new Map<string, { renewer: TlsRenewer; count: number }>();
/** Records that a plugin renews the certificate. Returns the undo. */
export function registerTlsRenewer(renewer: TlsRenewer): () => void {
const entry = renewers.get(renewer.pluginId) ?? { renewer, count: 0 };
entry.count++;
renewers.set(renewer.pluginId, entry);
let live = true;
return () => {
if (!live) return;
live = false;
const current = renewers.get(renewer.pluginId);
if (!current) return;
current.count--;
if (current.count <= 0) renewers.delete(renewer.pluginId);
};
}
export function currentTlsRenewer(): TlsRenewer | null {
const first = renewers.values().next();
return first.done ? null : first.value.renewer;
}
export async function getTlsStatus(): Promise<TlsStatus> {
const config = getTlsConfig();
let certificate: TlsCertificateInfo | null = null;
try {
certificate = readCertificateInfo(
await fs.readFile(config.certPath, "utf8"),
);
} catch {
certificate = null;
}
return {
enabled: config.enabled,
certificate,
renewal: currentTlsRenewer(),
};
}
/**
* Validates the pair, then swaps both files in with a rename so nginx or the
* HTTPS server never reads half a certificate. Does not reload.
*/
export async function writeTlsCertificate(
certificatePem: string,
privateKeyPem: string,
): Promise<TlsCertificateInfo> {
const info = validateCertificatePair(certificatePem, privateKeyPem);
const config = getTlsConfig();
await fs.mkdir(path.dirname(config.certPath), { recursive: true });
await fs.mkdir(path.dirname(config.keyPath), { recursive: true });
const certTmp = `${config.certPath}.tmp`;
const keyTmp = `${config.keyPath}.tmp`;
try {
await fs.writeFile(certTmp, certificatePem, { mode: 0o644 });
await fs.writeFile(keyTmp, privateKeyPem, { mode: 0o600 });
await fs.rename(keyTmp, config.keyPath);
await fs.rename(certTmp, config.certPath);
await fs.chmod(config.keyPath, 0o600).catch(() => undefined);
await fs.chmod(config.certPath, 0o644).catch(() => undefined);
} finally {
await fs.rm(certTmp, { force: true });
await fs.rm(keyTmp, { force: true });
}
systemLogger.info("TLS certificate replaced", {
operation: "tls_certificate_written",
subject: info.subject,
not_after: info.notAfter,
});
return info;
}
/** Serves the certificate on disk now, through nginx or the direct server. */
export async function reloadTls(): Promise<TlsReloadResult> {
const config = getTlsConfig();
if (config.terminatedByNginx) {
return reloadNginxWithSSL();
}
try {
const pair = await readPair(config);
if (directServer) {
directServer.setSecureContext(pair);
return { applied: true, message: "The new certificate is being served." };
}
process.env.ENABLE_SSL = "true";
const server = await startDirectServer(getTlsConfig());
if (!server) {
return {
applied: false,
message:
"Certificate installed, but HTTPS could not be started. Restart Termix with ENABLE_SSL=true to apply it.",
};
}
persistSSLEnv(String(config.port), config.certPath, config.keyPath);
return {
applied: true,
message: `HTTPS is now active on port ${config.port}.`,
};
} catch (error) {
systemLogger.error("Failed to reload the TLS certificate", error, {
operation: "tls_reload_failed",
});
return {
applied: false,
message:
"Certificate installed, but it could not be loaded. Restart Termix to apply it.",
};
}
}
@@ -0,0 +1,127 @@
/**
* Moves the 2.8 ACME configuration into the acme-ssl plugin.
*
* 2.8 kept it as one JSON row, `acme_ssl_settings`, in core settings. Each
* field becomes a plugin admin setting. "manual" was never ACME (it meant an
* uploaded certificate, which core still handles), so it turns renewal off.
* The Cloudflare token was plaintext and lands as an encrypted secret; it is
* then removed from the old row. The rest of the row stays for one release.
*
* Idempotent: a field already set in plugin_settings is never overwritten.
*/
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { encryptSystemSecret } from "../system-secret-crypto.js";
const PLUGIN_ID = "acme-ssl";
const LEGACY_KEY = "acme_ssl_settings";
export interface AcmeSslSettingsMigrationResult {
moved: string[];
}
interface LegacyAcmeSettings {
enabled?: unknown;
domain?: unknown;
email?: unknown;
challengeType?: unknown;
cloudflareToken?: unknown;
}
const text = (value: unknown) =>
typeof value === "string" && value.trim() ? value.trim() : null;
export async function runAcmeSslSettingsMigration(): Promise<AcmeSslSettingsMigrationResult> {
const result: AcmeSslSettingsMigrationResult = { moved: [] };
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
const settings = createCurrentSettingsRepository();
const raw = await settings.get(LEGACY_KEY);
if (!raw) return result;
let legacy: LegacyAcmeSettings;
try {
legacy = JSON.parse(raw) as LegacyAcmeSettings;
} catch {
return result;
}
if (!legacy || typeof legacy !== "object") return result;
const pluginSettings = createCurrentPluginSettingsRepository();
const writeIfUnset = async (
field: string,
value: unknown,
secret = false,
) => {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) return;
await pluginSettings.set(
PLUGIN_ID,
"admin",
null,
field,
JSON.stringify(value),
secret,
);
result.moved.push(field);
};
const challenge = legacy.challengeType;
const manual = challenge === "manual";
const domain = text(legacy.domain);
if (domain) await writeIfUnset("domain", domain);
const email = text(legacy.email);
if (email) await writeIfUnset("email", email);
if (challenge === "dns-cloudflare") {
await writeIfUnset("challengeType", "dns-cloudflare");
} else if (challenge === "http-webroot") {
await writeIfUnset("challengeType", "http-01");
}
if (typeof legacy.enabled === "boolean") {
await writeIfUnset("autoRenew", legacy.enabled && !manual);
}
const token = text(legacy.cloudflareToken);
if (token) {
await writeIfUnset(
"cloudflareToken",
await encryptSystemSecret(token),
true,
);
const { cloudflareToken: _dropped, ...rest } = legacy;
await settings.set(LEGACY_KEY, JSON.stringify(rest));
}
if (result.moved.length > 0) {
databaseLogger.info(
`Moved ${result.moved.length} ACME setting(s) into plugin settings`,
{
operation: "acme_ssl_settings_migration",
moved: result.moved.join(", "),
},
);
}
} catch (error) {
// Must not stop the backend: the plugin then shows "not configured".
databaseLogger.warn("ACME settings migration failed", {
operation: "acme_ssl_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
return result;
}
-59
View File
@@ -1,59 +0,0 @@
import { authApi, handleApiError } from "@/main-axios";
export type AcmeChallengeType = "http-webroot" | "dns-cloudflare" | "manual";
export type AcmeSettings = {
enabled: boolean;
domain: string;
email: string;
challengeType: AcmeChallengeType;
cloudflareToken: string;
lastIssuedAt: string | null;
certStatus: "none" | "valid" | "expiring" | "expired";
certExpiresAt: string | null;
};
export async function getAcmeSslSettings(): Promise<AcmeSettings> {
try {
const response = await authApi.get("/users/acme-ssl-settings");
return response.data;
} catch (error) {
handleApiError(error, "fetch ACME SSL settings");
}
}
export async function updateAcmeSslSettings(
settings: Partial<
Omit<AcmeSettings, "certStatus" | "certExpiresAt" | "lastIssuedAt">
>,
): Promise<AcmeSettings> {
try {
const response = await authApi.patch("/users/acme-ssl-settings", settings);
return response.data;
} catch (error) {
handleApiError(error, "update ACME SSL settings");
}
}
export async function requestAcmeCertificate(): Promise<
AcmeSettings & { success: boolean; reloadMessage?: string }
> {
try {
const response = await authApi.post("/users/acme-ssl-request", {});
return response.data;
} catch (error) {
handleApiError(error, "request ACME certificate");
}
}
export async function uploadManualSslCertificate(payload: {
certificate: string;
privateKey: string;
}): Promise<AcmeSettings & { success: boolean; reloadMessage?: string }> {
try {
const response = await authApi.post("/users/manual-ssl-upload", payload);
return response.data;
} catch (error) {
handleApiError(error, "upload manual SSL certificate");
}
}
+38
View File
@@ -0,0 +1,38 @@
import { authApi, handleApiError } from "@/main-axios";
export type TlsCertificateInfo = {
subject: string;
issuer: string;
names: string[];
notBefore: string;
notAfter: string;
selfSigned: boolean;
fingerprint: string;
};
export type TlsStatus = {
enabled: boolean;
certificate: TlsCertificateInfo | null;
renewal: { pluginId: string; pluginName: string } | null;
};
export async function getTlsStatus(): Promise<TlsStatus> {
try {
const response = await authApi.get("/users/tls-certificate");
return response.data;
} catch (error) {
handleApiError(error, "fetch TLS certificate status");
}
}
export async function uploadTlsCertificate(payload: {
certificate: string;
privateKey: string;
}): Promise<TlsStatus & { success: boolean; reloadMessage?: string }> {
try {
const response = await authApi.post("/users/tls-certificate", payload);
return response.data;
} catch (error) {
handleApiError(error, "upload TLS certificate");
}
}
+10 -27
View File
@@ -1554,7 +1554,7 @@
"sectionDatabase": "Database",
"sectionApiKeys": "API Keys",
"sectionAuditLog": "Audit Log",
"sectionSsl": "SSL / Let's Encrypt",
"sectionSsl": "HTTPS certificate",
"sectionBranding": "White Label",
"brandingAppName": "Application Name",
"brandingAppNameDesc": "Shown on the login screen and browser tab. Leave empty to use \"Termix\".",
@@ -1567,38 +1567,15 @@
"brandingLogoInvalid": "Invalid image. Use a PNG, JPEG or WEBP file up to 750 KB.",
"brandingSaved": "Branding settings saved",
"brandingSaveFailed": "Failed to save branding settings",
"sslDescription": "Automatically issue and renew a trusted SSL certificate from Let's Encrypt. Requires a public domain and port 80 or DNS access.",
"sslDescription": "The certificate Termix serves over HTTPS. Upload your own here, or let the ACME Certificates plugin get and renew one from Let's Encrypt.",
"sslDocsLink": "View SSL docs",
"sslDomain": "Domain",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "The public domain name for the certificate.",
"sslEmail": "Email",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Contact email for Let's Encrypt notifications and account.",
"sslChallengeType": "Challenge Type",
"sslChallengeTypeDesc": "How to prove domain ownership to Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - requires port 80 accessible from the internet",
"sslChallengeDns": "DNS (Cloudflare) - requires a Cloudflare API token",
"sslCloudflareToken": "Cloudflare API Token",
"sslCloudflareTokenPlaceholder": "Enter token...",
"sslCloudflareTokenDesc": "Scoped token with Zone:DNS:Edit permission for your domain.",
"sslCertStatus": "Certificate Status",
"sslCertStatusNone": "No certificate",
"sslCertStatusValid": "Valid",
"sslCertStatusExpiring": "Expiring soon",
"sslCertStatusExpired": "Expired",
"sslCertExpiresAt": "Expires {{date}}",
"sslLastIssued": "Last issued {{date}}",
"sslRequestCert": "Issue / Renew Certificate",
"sslRequestCertLoading": "Requesting certificate...",
"sslRequestCertSuccess": "Certificate issued and installed successfully",
"sslRequestCertFailed": "Certificate request failed",
"sslSave": "Save Settings",
"sslSaved": "SSL settings saved",
"sslSaveFailed": "Failed to save SSL settings",
"sslRequiresDomain": "Domain and email are required",
"sslInfoNote": "After issuing or uploading a certificate, Termix will automatically enable HTTPS on SSL_PORT (default 8443) in addition to the existing HTTP port. Make sure that port is published/mapped to this container so it is reachable.",
"sslManualOption": "Manual (upload certificate)",
"sslManualCert": "Certificate (PEM)",
"sslManualCertPlaceholder": "-----BEGIN CERTIFICATE-----",
"sslManualKey": "Private Key (PEM)",
@@ -1921,7 +1898,13 @@
"resetSecondFactorsConfirm": "Remove every second factor for {{username}}? They can sign in with their password or SSO and enrol again.",
"secondFactorsResetSuccess": "Second factors reset",
"secondFactorsResetFailed": "Failed to reset second factors",
"passwordLoginForced": "Password login is turned off, but no other login method is enabled, so it stays on for now. Nobody gets locked out."
"passwordLoginForced": "Password login is turned off, but no other login method is enabled, so it stays on for now. Nobody gets locked out.",
"sslNotRenewed": "Nothing renews this certificate. It expires {{date}}. Turn on the ACME Certificates plugin and its automatic renewal, or upload a new one before then.",
"sslSelfSigned": "Self-signed certificate made by Termix. Browsers will warn about it.",
"sslRenewedBy": "Renewed automatically by {{plugin}}",
"sslIssuer": "Issued by {{issuer}}",
"sslNames": "Covers {{names}}",
"sslManualTitle": "Upload a certificate"
},
"newUi": {
"sidebar": {
@@ -2513,7 +2496,7 @@
},
"system:tls": {
"title": "Manage certificates",
"consequence": "It can request and replace the server certificate."
"consequence": "It can replace the certificate Termix serves over HTTPS and answer domain checks from certificate authorities."
},
"device:serial": {
"title": "Access serial and USB devices",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Kon nie handelsmerkinstellings stoor nie",
"sslDescription": "Reik outomaties 'n vertroude SSL-sertifikaat uit en hernu dit van Let's Encrypt. Vereis 'n publieke domein en poort 80 of DNS-toegang.",
"sslDocsLink": "Bekyk SSL-dokumente",
"sslDomain": "Domein",
"sslDomainPlaceholder": "termix.voorbeeld.com",
"sslDomainDesc": "Die publieke domeinnaam vir die sertifikaat.",
"sslEmail": "E-pos",
"sslEmailPlaceholder": "admin@voorbeeld.com",
"sslEmailDesc": "Kontak-e-posadres vir Let's Encrypt-kennisgewings en -rekening.",
"sslChallengeType": "Uitdagingsoort",
"sslChallengeTypeDesc": "Hoe om domein-eienaarskap aan Let's Encrypt te bewys.",
"sslChallengeHttp": "HTTP (webroot) - vereis poort 80 wat vanaf die internet toeganklik is",
"sslChallengeDns": "DNS (Cloudflare) - vereis 'n Cloudflare API-token",
"sslCloudflareToken": "Cloudflare API-token",
"sslCloudflareTokenPlaceholder": "Voer teken in...",
"sslCloudflareTokenDesc": "Omvangryke token met Zone:DNS:Wysig toestemming vir jou domein.",
"sslCertStatus": "Sertifikaatstatus",
"sslCertStatusNone": "Geen sertifikaat nie",
"sslCertStatusValid": "Geldig",
"sslCertStatusExpiring": "Verval binnekort",
"sslCertStatusExpired": "Verstryk",
"sslCertExpiresAt": "Vervaldatum {{date}}",
"sslLastIssued": "Laas uitgereik {{date}}",
"sslRequestCert": "Uitreik / Hernu Sertifikaat",
"sslRequestCertLoading": "Versoek sertifikaat...",
"sslRequestCertSuccess": "Sertifikaat suksesvol uitgereik en geïnstalleer",
"sslRequestCertFailed": "Sertifikaatversoek het misluk",
"sslSave": "Stoor instellings",
"sslSaved": "SSL-instellings gestoor",
"sslSaveFailed": "Kon nie SSL-instellings stoor nie",
"sslRequiresDomain": "Domein en e-posadres word vereis",
"sslInfoNote": "Nadat 'n sertifikaat uitgereik of opgelaai is, sal Termix outomaties HTTPS op SSL_PORT (standaard 8443) aktiveer, benewens die bestaande HTTP-poort. Maak seker dat die poort gepubliseer/gekarteer is aan hierdie houer sodat dit bereikbaar is.",
"sslManualOption": "Handleiding (laai sertifikaat op)",
"sslManualCert": "Sertifikaat (PEM)",
"sslManualCertPlaceholder": "-----BEGIN SERTIFIKAAT-----",
"sslManualKey": "Privaatsleutel (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "فشل حفظ إعدادات العلامة التجارية",
"sslDescription": "إصدار وتجديد شهادة SSL موثوقة تلقائيًا من Let's Encrypt. يتطلب نطاقًا عامًا والمنفذ 80 أو وصول DNS.",
"sslDocsLink": "عرض مستندات SSL",
"sslDomain": "النطاق",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "اسم النطاق العام للشهادة.",
"sslEmail": "البريد الإلكتروني",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "بريد الاتصال لإشعارات Let's Encrypt والحساب.",
"sslChallengeType": "نوع التحدي",
"sslChallengeTypeDesc": "كيفية إثبات ملكية النطاق لـ Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - يتطلب المنفذ 80 متاحًا من الإنترنت",
"sslChallengeDns": "DNS (Cloudflare) - يتطلب رمز Cloudflare API",
"sslCloudflareToken": "رمز Cloudflare API",
"sslCloudflareTokenPlaceholder": "أدخل الرمز...",
"sslCloudflareTokenDesc": "رمز مقيد بصلاحية Zone:DNS:Edit للنطاق الخاص بك.",
"sslCertStatus": "حالة الشهادة",
"sslCertStatusNone": "لا توجد شهادة",
"sslCertStatusValid": "صالحة",
"sslCertStatusExpiring": "ستنتهي قريبًا",
"sslCertStatusExpired": "منتهية",
"sslCertExpiresAt": "تنتهي في {{date}}",
"sslLastIssued": "آخر إصدار {{date}}",
"sslRequestCert": "إصدار / تجديد الشهادة",
"sslRequestCertLoading": "جارٍ طلب الشهادة...",
"sslRequestCertSuccess": "تم إصدار الشهادة وتثبيتها بنجاح",
"sslRequestCertFailed": "فشل طلب الشهادة",
"sslSave": "حفظ الإعدادات",
"sslSaved": "تم حفظ إعدادات SSL",
"sslSaveFailed": "فشل حفظ إعدادات SSL",
"sslRequiresDomain": "النطاق والبريد الإلكتروني مطلوبان",
"sslInfoNote": "بعد إصدار الشهادة أو تحميلها، سيقوم Termix تلقائيًا بتفعيل بروتوكول HTTPS على منفذ SSL_PORT (المنفذ الافتراضي 8443) بالإضافة إلى منفذ HTTP الحالي. تأكد من نشر هذا المنفذ/ربطه بهذه الحاوية لضمان إمكانية الوصول إليه.",
"sslManualOption": "يدوي (تحميل الشهادة)",
"sslManualCert": "شهادة (PEM)",
"sslManualCertPlaceholder": "-----بداية الشهادة-----",
"sslManualKey": "المفتاح الخاص (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Запазването на настройките за брандиране не бе успешно",
"sslDescription": "Автоматично издаване и подновяване на надежден SSL сертификат от Let's Encrypt. Изисква публичен домейн и порт 80 или DNS достъп.",
"sslDocsLink": "Преглед на SSL документите",
"sslDomain": "Домейн",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Публичното име на домейна за сертификата.",
"sslEmail": "Имейл",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Контактен имейл за известия и акаунт от Let's Encrypt.",
"sslChallengeType": "Тип предизвикателство",
"sslChallengeTypeDesc": "Как да докажете собствеността на домейн на Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - изисква порт 80, достъпен от интернет",
"sslChallengeDns": "DNS (Cloudflare) - изисква Cloudflare API токен",
"sslCloudflareToken": "API токен на Cloudflare",
"sslCloudflareTokenPlaceholder": "Въведете токен...",
"sslCloudflareTokenDesc": "Ограничен токен с разрешение Zone:DNS:Edit за вашия домейн.",
"sslCertStatus": "Статус на сертификата",
"sslCertStatusNone": "Няма сертификат",
"sslCertStatusValid": "Валиден",
"sslCertStatusExpiring": "Изтича скоро",
"sslCertStatusExpired": "Изтекъл",
"sslCertExpiresAt": "Изтича {{date}}",
"sslLastIssued": "Последно издадено {{date}}",
"sslRequestCert": "Издаване / Подновяване на сертификат",
"sslRequestCertLoading": "Заявка за сертификат...",
"sslRequestCertSuccess": "Сертификатът е издаден и инсталиран успешно",
"sslRequestCertFailed": "Заявката за сертификат е неуспешна",
"sslSave": "Запазване на настройките",
"sslSaved": "Настройките на SSL са запазени",
"sslSaveFailed": "Запазването на настройките за SSL не бе успешно",
"sslRequiresDomain": "Домейнът и имейлът са задължителни",
"sslInfoNote": "След издаване или качване на сертификат, Termix автоматично ще активира HTTPS на SSL_PORT (по подразбиране 8443) в допълнение към съществуващия HTTP порт. Уверете се, че този порт е публикуван/свързан с този контейнер, така че да е достъпен.",
"sslManualOption": "Ръководство (сертификат за качване)",
"sslManualCert": "Сертификат (PEM)",
"sslManualCertPlaceholder": "-----ЗАПОЧВАНЕ НА СЕРТИФИКАТ-----",
"sslManualKey": "Частен ключ (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "ব্র্যান্ডিং সেটিংস সংরক্ষণ করতে ব্যর্থ হয়েছে",
"sslDescription": "Let's Encrypt থেকে স্বয়ংক্রিয়ভাবে একটি বিশ্বস্ত SSL সার্টিফিকেট ইস্যু ও নবায়ন করুন। এর জন্য একটি পাবলিক ডোমেইন এবং পোর্ট ৮০ অথবা DNS অ্যাক্সেস প্রয়োজন।",
"sslDocsLink": "SSL ডকুমেন্টেশন দেখুন",
"sslDomain": "ডোমেইন",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "সার্টিফিকেটটির পাবলিক ডোমেইন নাম।",
"sslEmail": "ইমেল",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Let's Encrypt-এর বিজ্ঞপ্তি এবং অ্যাকাউন্টের জন্য যোগাযোগের ইমেল।",
"sslChallengeType": "চ্যালেঞ্জের ধরণ",
"sslChallengeTypeDesc": "Let's Encrypt-কে ডোমেইনের মালিকানা কীভাবে প্রমাণ করবেন।",
"sslChallengeHttp": "HTTP (webroot) - এর জন্য ইন্টারনেট থেকে অ্যাক্সেসযোগ্য পোর্ট ৮০ প্রয়োজন।",
"sslChallengeDns": "ডিএনএস (ক্লাউডফ্লেয়ার) - এর জন্য একটি ক্লাউডফ্লেয়ার এপিআই টোকেন প্রয়োজন।",
"sslCloudflareToken": "ক্লাউডফ্লেয়ার এপিআই টোকেন",
"sslCloudflareTokenPlaceholder": "টোকেন প্রবেশ করান...",
"sslCloudflareTokenDesc": "আপনার ডোমেনের জন্য Zone:DNS:Edit অনুমতিসহ স্কোপড টোকেন।",
"sslCertStatus": "সার্টিফিকেটের অবস্থা",
"sslCertStatusNone": "কোন সার্টিফিকেট নেই",
"sslCertStatusValid": "বৈধ",
"sslCertStatusExpiring": "শীঘ্রই মেয়াদ শেষ হবে",
"sslCertStatusExpired": "মেয়াদোত্তীর্ণ",
"sslCertExpiresAt": "মেয়াদ শেষ হবে {{date}}",
"sslLastIssued": "সর্বশেষ প্রকাশিত {{date}}",
"sslRequestCert": "সার্টিফিকেট ইস্যু / নবায়ন করুন",
"sslRequestCertLoading": "সার্টিফিকেটের জন্য অনুরোধ...",
"sslRequestCertSuccess": "সার্টিফিকেট সফলভাবে ইস্যু এবং ইনস্টল করা হয়েছে।",
"sslRequestCertFailed": "সার্টিফিকেট অনুরোধ ব্যর্থ হয়েছে",
"sslSave": "সেটিংস সংরক্ষণ করুন",
"sslSaved": "SSL সেটিংস সংরক্ষিত হয়েছে",
"sslSaveFailed": "SSL সেটিংস সংরক্ষণ করতে ব্যর্থ হয়েছে",
"sslRequiresDomain": "ডোমেইন এবং ইমেল প্রয়োজন",
"sslInfoNote": "সার্টিফিকেট ইস্যু বা আপলোড করার পরে, Termix বিদ্যমান HTTP পোর্টের পাশাপাশি SSL_PORT (ডিফল্ট ৮৪৪৩)-এ স্বয়ংক্রিয়ভাবে HTTPS চালু করে দেবে। নিশ্চিত করুন যে পোর্টটি এই কন্টেইনারে পাবলিশ/ম্যাপ করা আছে, যাতে এটি অ্যাক্সেসযোগ্য হয়।",
"sslManualOption": "ম্যানুয়াল (শংসাপত্র আপলোড করুন)",
"sslManualCert": "সার্টিফিকেট (পিইএম)",
"sslManualCertPlaceholder": "-----সনদপত্র শুরু-----",
"sslManualKey": "ব্যক্তিগত কী (পিইএম)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "No s'ha pogut desar la configuració de marca",
"sslDescription": "Emet i renova automàticament un certificat SSL de confiança de Let's Encrypt. Requereix un domini públic i el port 80 o accés DNS.",
"sslDocsLink": "Veure documents SSL",
"sslDomain": "Domini",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "El nom de domini públic del certificat.",
"sslEmail": "Correu electrònic",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Correu electrònic de contacte per a les notificacions i el compte de Let's Encrypt.",
"sslChallengeType": "Tipus de repte",
"sslChallengeTypeDesc": "Com demostrar la propietat del domini a Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot): requereix el port 80 accessible des d'Internet",
"sslChallengeDns": "DNS (Cloudflare): requereix un token d'API de Cloudflare",
"sslCloudflareToken": "Token de l'API de Cloudflare",
"sslCloudflareTokenPlaceholder": "Introduïu el testimoni...",
"sslCloudflareTokenDesc": "Token amb àmbit i permís Zone:DNS:Edit per al vostre domini.",
"sslCertStatus": "Estat del certificat",
"sslCertStatusNone": "Sense certificat",
"sslCertStatusValid": "Vàlid",
"sslCertStatusExpiring": "Caduca aviat",
"sslCertStatusExpired": "Caducat",
"sslCertExpiresAt": "Caduca {{date}}",
"sslLastIssued": "Última emissió {{date}}",
"sslRequestCert": "Emetre / Renovar el certificat",
"sslRequestCertLoading": "Sol·licitant certificat...",
"sslRequestCertSuccess": "Certificat emès i instal·lat correctament",
"sslRequestCertFailed": "La sol·licitud de certificat ha fallat",
"sslSave": "Desa la configuració",
"sslSaved": "Configuració SSL desada",
"sslSaveFailed": "No s'ha pogut desar la configuració SSL",
"sslRequiresDomain": "Cal el domini i el correu electrònic",
"sslInfoNote": "Després d'emetre o carregar un certificat, Termix habilitarà automàticament HTTPS a SSL_PORT (per defecte 8443) a més del port HTTP existent. Assegureu-vos que el port estigui publicat/mapejat a aquest contenidor perquè sigui accessible.",
"sslManualOption": "Manual (pujar certificat)",
"sslManualCert": "Certificat (PEM)",
"sslManualCertPlaceholder": "-----CERTIFICAT D'INICI-----",
"sslManualKey": "Clau privada (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Uložení nastavení brandingu se nezdařilo",
"sslDescription": "Automaticky vydá a obnoví důvěryhodný SSL certifikát od Let's Encrypt. Vyžaduje veřejnou doménu a port 80 nebo přístup k DNS.",
"sslDocsLink": "Zobrazit dokumentaci k SSL",
"sslDomain": "Doména",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Název veřejné domény pro certifikát.",
"sslEmail": "E-mail",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Kontaktní e-mail pro oznámení a účet Let's Encrypt.",
"sslChallengeType": "Typ výzvy",
"sslChallengeTypeDesc": "Jak prokázat vlastnictví domény službě Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) – vyžaduje port 80 přístupný z internetu",
"sslChallengeDns": "DNS (Cloudflare) – vyžaduje token Cloudflare API",
"sslCloudflareToken": "Token API Cloudflare",
"sslCloudflareTokenPlaceholder": "Zadejte token...",
"sslCloudflareTokenDesc": "Token s vymezeným rozsahem a oprávněním Zóna:DNS:Úpravy pro vaši doménu.",
"sslCertStatus": "Stav certifikátu",
"sslCertStatusNone": "Žádný certifikát",
"sslCertStatusValid": "Platný",
"sslCertStatusExpiring": "Platnost brzy vyprší",
"sslCertStatusExpired": "Platnost vypršela",
"sslCertExpiresAt": "Platnost vyprší {{date}}",
"sslLastIssued": "Naposledy vydáno {{date}}",
"sslRequestCert": "Vydání / Obnova certifikátu",
"sslRequestCertLoading": "Žádost o certifikát...",
"sslRequestCertSuccess": "Certifikát byl úspěšně vydán a nainstalován.",
"sslRequestCertFailed": "Žádost o certifikát selhala",
"sslSave": "Uložit nastavení",
"sslSaved": "Nastavení SSL uloženo",
"sslSaveFailed": "Nepodařilo se uložit nastavení SSL",
"sslRequiresDomain": "Doména a e-mail jsou povinné",
"sslInfoNote": "Po vydání nebo nahrání certifikátu Termix automaticky povolí HTTPS na SSL_PORT (výchozí 8443) a to vedle stávajícího HTTP portu. Ujistěte se, že je tento port publikován/mapován na tento kontejner, aby byl dostupný.",
"sslManualOption": "Manuál (nahrání certifikátu)",
"sslManualCert": "Certifikát (PEM)",
"sslManualCertPlaceholder": "-----ZAČÁTEK CERTIFIKÁTU-----",
"sslManualKey": "Soukromý klíč (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Brandingindstillingerne kunne ikke gemmes",
"sslDescription": "Udsted og forny automatisk et betroet SSL-certifikat fra Let's Encrypt. Kræver et offentligt domæne og port 80 eller DNS-adgang.",
"sslDocsLink": "Se SSL-dokumenter",
"sslDomain": "Domæne",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Det offentlige domænenavn for certifikatet.",
"sslEmail": "E-mail",
"sslEmailPlaceholder": "admin@eksempel.com",
"sslEmailDesc": "Kontakt-e-mailadresse for Let's Encrypt-notifikationer og konto.",
"sslChallengeType": "Udfordringstype",
"sslChallengeTypeDesc": "Sådan beviser du domæneejerskab over for Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - kræver port 80, der er tilgængelig fra internettet",
"sslChallengeDns": "DNS (Cloudflare) - kræver et Cloudflare API-token",
"sslCloudflareToken": "Cloudflare API-token",
"sslCloudflareTokenPlaceholder": "Indtast token...",
"sslCloudflareTokenDesc": "Scoped-token med Zone:DNS:Rediger-tilladelse for dit domæne.",
"sslCertStatus": "Certifikatstatus",
"sslCertStatusNone": "Intet certifikat",
"sslCertStatusValid": "Gyldig",
"sslCertStatusExpiring": "Udløber snart",
"sslCertStatusExpired": "Udløbet",
"sslCertExpiresAt": "Udløber {{date}}",
"sslLastIssued": "Sidst udstedt {{date}}",
"sslRequestCert": "Udstede/forny certifikat",
"sslRequestCertLoading": "Anmoder om certifikat...",
"sslRequestCertSuccess": "Certifikat udstedt og installeret",
"sslRequestCertFailed": "Certifikatanmodning mislykkedes",
"sslSave": "Gem indstillinger",
"sslSaved": "SSL-indstillinger gemt",
"sslSaveFailed": "Kunne ikke gemme SSL-indstillinger",
"sslRequiresDomain": "Domæne og e-mail er påkrævet",
"sslInfoNote": "Efter udstedelse eller upload af et certifikat vil Termix automatisk aktivere HTTPS på SSL_PORT (standard 8443) ud over den eksisterende HTTP-port. Sørg for, at porten er publiceret/tilknyttet denne container, så den er tilgængelig.",
"sslManualOption": "Manual (upload certifikat)",
"sslManualCert": "Certifikat (PEM)",
"sslManualCertPlaceholder": "-----STARTCERTIFIKAT-----",
"sslManualKey": "Privat nøgle (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Branding-Einstellungen konnten nicht gespeichert werden",
"sslDescription": "Automatisch ein vertrauenswürdiges SSL-Zertifikat von Let's Encrypt ausstellen und erneuern. Erfordert eine öffentliche Domain und Port 80 oder DNS-Zugriff.",
"sslDocsLink": "SSL-Dokumentation anzeigen",
"sslDomain": "Domain",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Der öffentliche Domainname für das Zertifikat.",
"sslEmail": "E-Mail",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Kontakt-E-Mail für Let's Encrypt-Benachrichtigungen und das Konto.",
"sslChallengeType": "Challenge-Typ",
"sslChallengeTypeDesc": "Wie der Domainbesitz gegenüber Let's Encrypt nachgewiesen wird.",
"sslChallengeHttp": "HTTP (Webroot) – erfordert einen aus dem Internet erreichbaren Port 80",
"sslChallengeDns": "DNS (Cloudflare) – erfordert einen Cloudflare-API-Token",
"sslCloudflareToken": "Cloudflare-API-Token",
"sslCloudflareTokenPlaceholder": "Token eingeben...",
"sslCloudflareTokenDesc": "Bereichsbezogener Token mit Zone:DNS:Edit-Berechtigung für Ihre Domain.",
"sslCertStatus": "Zertifikatsstatus",
"sslCertStatusNone": "Kein Zertifikat",
"sslCertStatusValid": "Gültig",
"sslCertStatusExpiring": "Läuft bald ab",
"sslCertStatusExpired": "Abgelaufen",
"sslCertExpiresAt": "Läuft ab {{date}}",
"sslLastIssued": "Zuletzt ausgestellt {{date}}",
"sslRequestCert": "Zertifikat ausstellen / erneuern",
"sslRequestCertLoading": "Zertifikat wird angefordert...",
"sslRequestCertSuccess": "Zertifikat erfolgreich ausgestellt und installiert",
"sslRequestCertFailed": "Zertifikatsanforderung fehlgeschlagen",
"sslSave": "Einstellungen speichern",
"sslSaved": "SSL-Einstellungen gespeichert",
"sslSaveFailed": "Fehler beim Speichern der SSL-Einstellungen",
"sslRequiresDomain": "Domain und E-Mail sind erforderlich",
"sslInfoNote": "Nach der Ausstellung oder dem Hochladen eines Zertifikats aktiviert Termix automatisch HTTPS auf dem SSL_PORT (Standard: 8443) zusätzlich zum bestehenden HTTP-Port. Stellen Sie sicher, dass dieser Port für diesen Container freigegeben/zugeordnet ist, damit er erreichbar ist.",
"sslManualOption": "Handbuch (Zertifikat hochladen)",
"sslManualCert": "Zertifikat (PEM)",
"sslManualCertPlaceholder": "-----BEGINN DES ZERTIFIKATS-----",
"sslManualKey": "Privater Schlüssel (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Αποτυχία αποθήκευσης ρυθμίσεων επωνυμίας",
"sslDescription": "Αυτόματη έκδοση και ανανέωση ενός αξιόπιστου πιστοποιητικού SSL από το Let's Encrypt. Απαιτείται δημόσιος τομέας και πρόσβαση στη θύρα 80 ή DNS.",
"sslDocsLink": "Προβολή εγγράφων SSL",
"sslDomain": "Πεδίο ορισμού",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Το όνομα δημόσιου τομέα για το πιστοποιητικό.",
"sslEmail": "E-mail",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Ηλεκτρονική διεύθυνση ηλεκτρονικού ταχυδρομείου επικοινωνίας για τις ειδοποιήσεις και τον λογαριασμό Let's Encrypt.",
"sslChallengeType": "Τύπος πρόκλησης",
"sslChallengeTypeDesc": "Πώς να αποδείξετε την κατοχή του domain στο Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - απαιτεί θύρα 80 προσβάσιμη από το διαδίκτυο",
"sslChallengeDns": "DNS (Cloudflare) - απαιτεί διακριτικό API Cloudflare",
"sslCloudflareToken": "Διακριτικό API Cloudflare",
"sslCloudflareTokenPlaceholder": "Εισαγάγετε διακριτικό...",
"sslCloudflareTokenDesc": "Διακριτικό πεδίου εφαρμογής με δικαίωμα Zone:DNS:Edit για τον τομέα σας.",
"sslCertStatus": "Κατάσταση πιστοποιητικού",
"sslCertStatusNone": "Δεν υπάρχει πιστοποιητικό",
"sslCertStatusValid": "Εγκυρος",
"sslCertStatusExpiring": "Λήγει σύντομα",
"sslCertStatusExpired": "Λήξη",
"sslCertExpiresAt": "Λήγει {{date}}",
"sslLastIssued": "Τελευταία έκδοση {{date}}",
"sslRequestCert": "Έκδοση / Ανανέωση Πιστοποιητικού",
"sslRequestCertLoading": "Αίτημα πιστοποιητικού...",
"sslRequestCertSuccess": "Το πιστοποιητικό εκδόθηκε και εγκαταστάθηκε με επιτυχία",
"sslRequestCertFailed": "Το αίτημα πιστοποιητικού απέτυχε",
"sslSave": "Αποθήκευση ρυθμίσεων",
"sslSaved": "Οι ρυθμίσεις SSL αποθηκεύτηκαν",
"sslSaveFailed": "Αποτυχία αποθήκευσης ρυθμίσεων SSL",
"sslRequiresDomain": "Απαιτούνται ο τομέας και το email",
"sslInfoNote": "Μετά την έκδοση ή την αποστολή ενός πιστοποιητικού, το Termix θα ενεργοποιήσει αυτόματα το HTTPS στο SSL_PORT (προεπιλογή 8443) επιπλέον της υπάρχουσας θύρας HTTP. Βεβαιωθείτε ότι η θύρα έχει δημοσιευτεί/αντιστοιχιστεί σε αυτό το κοντέινερ, ώστε να είναι προσβάσιμη.",
"sslManualOption": "Εγχειρίδιο (μεταφόρτωση πιστοποιητικού)",
"sslManualCert": "Πιστοποιητικό (PEM)",
"sslManualCertPlaceholder": "-----ΕΝΑΡΞΗ ΠΙΣΤΟΠΟΙΗΤΙΚΟΥ-----",
"sslManualKey": "Ιδιωτικό Κλειδί (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "No se pudieron guardar los ajustes de marca.",
"sslDescription": "Emitir y renovar automáticamente un certificado SSL de confianza de Let's Encrypt. Requiere un dominio público y acceso al puerto 80 o DNS.",
"sslDocsLink": "Ver documentación SSL",
"sslDomain": "Dominio",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "El nombre de dominio público para el certificado.",
"sslEmail": "Correo electrónico",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Correo electrónico de contacto para notificaciones y cuenta de Let's Encrypt.",
"sslChallengeType": "Tipo de desafío",
"sslChallengeTypeDesc": "Cómo demostrar la propiedad del dominio a Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - requiere el puerto 80 accesible desde internet",
"sslChallengeDns": "DNS (Cloudflare) - requiere un token de API de Cloudflare",
"sslCloudflareToken": "Token de API de Cloudflare",
"sslCloudflareTokenPlaceholder": "Introducir token...",
"sslCloudflareTokenDesc": "Token con permiso Zone:DNS:Edit limitado para tu dominio.",
"sslCertStatus": "Estado del certificado",
"sslCertStatusNone": "Sin certificado",
"sslCertStatusValid": "Válido",
"sslCertStatusExpiring": "Caduca pronto",
"sslCertStatusExpired": "Caducado",
"sslCertExpiresAt": "Caduca {{date}}",
"sslLastIssued": "Última emisión {{date}}",
"sslRequestCert": "Emitir / Renovar certificado",
"sslRequestCertLoading": "Solicitando certificado...",
"sslRequestCertSuccess": "Certificado emitido e instalado correctamente",
"sslRequestCertFailed": "La solicitud del certificado falló",
"sslSave": "Guardar configuración",
"sslSaved": "Configuración SSL guardada",
"sslSaveFailed": "Error al guardar la configuración SSL",
"sslRequiresDomain": "Se requieren dominio y correo electrónico",
"sslInfoNote": "Tras emitir o cargar un certificado, Termix habilitará automáticamente HTTPS en SSL_PORT (por defecto, 8443), además del puerto HTTP existente. Asegúrese de que dicho puerto esté publicado o asignado a este contenedor para que sea accesible.",
"sslManualOption": "Manual (subir certificado)",
"sslManualCert": "Certificado (PEM)",
"sslManualCertPlaceholder": "-----INICIO DEL CERTIFICADO-----",
"sslManualKey": "Clave privada (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Brändäysasetusten tallentaminen epäonnistui",
"sslDescription": "Myönnä ja uusi automaattisesti Let's Encryptin luotettava SSL-varmenne. Vaatii julkisen verkkotunnuksen ja portin 80 tai DNS-yhteyden.",
"sslDocsLink": "Näytä SSL-dokumentit",
"sslDomain": "Verkkotunnus",
"sslDomainPlaceholder": "termix.esimerkki.com",
"sslDomainDesc": "Varmenteen julkinen verkkotunnus.",
"sslEmail": "Sähköposti",
"sslEmailPlaceholder": "admin@esimerkki.com",
"sslEmailDesc": "Let's Encrypt -ilmoitusten ja -tilin yhteystiedot.",
"sslChallengeType": "Haasteen tyyppi",
"sslChallengeTypeDesc": "Kuinka todistaa verkkotunnuksen omistajuus Let's Encryptille.",
"sslChallengeHttp": "HTTP (webroot) – vaatii internetistä saatavilla olevan portin 80",
"sslChallengeDns": "DNS (Cloudflare) – vaatii Cloudflare API -tunnuksen",
"sslCloudflareToken": "Cloudflare API-tunnus",
"sslCloudflareTokenPlaceholder": "Syötä tunnus...",
"sslCloudflareTokenDesc": "Laajuusaluetunnus, jolla on Zone:DNS:Edit-käyttöoikeus verkkotunnuksellesi.",
"sslCertStatus": "Sertifikaatin tila",
"sslCertStatusNone": "Ei todistusta",
"sslCertStatusValid": "Voimassa",
"sslCertStatusExpiring": "Vanhenee pian",
"sslCertStatusExpired": "Vanhentunut",
"sslCertExpiresAt": "Vanhenee {{date}}",
"sslLastIssued": "Viimeksi myönnetty {{date}}",
"sslRequestCert": "Myönnä / Uudista sertifikaatti",
"sslRequestCertLoading": "Pyydetään varmennetta...",
"sslRequestCertSuccess": "Varmenne myönnetty ja asennettu onnistuneesti",
"sslRequestCertFailed": "Varmennepyyntö epäonnistui",
"sslSave": "Tallenna asetukset",
"sslSaved": "SSL-asetukset tallennettu",
"sslSaveFailed": "SSL-asetusten tallentaminen epäonnistui",
"sslRequiresDomain": "Verkkotunnus ja sähköpostiosoite vaaditaan",
"sslInfoNote": "Kun varmenne on myönnetty tai ladattu, Termix ottaa automaattisesti käyttöön HTTPS:n SSL_PORT-portissa (oletusarvo 8443) olemassa olevan HTTP-portin lisäksi. Varmista, että portti on julkaistu/yhdistetty tähän säilöön, jotta se on tavoitettavissa.",
"sslManualOption": "Manuaalinen (lataa varmenne)",
"sslManualCert": "Todistus (PEM)",
"sslManualCertPlaceholder": "-----ALKUTODISTUS-----",
"sslManualKey": "Yksityinen avain (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Impossible d'enregistrer les paramètres de marque",
"sslDescription": "Émettre et renouveler automatiquement un certificat SSL de confiance via Let's Encrypt. Requiert un domaine public et un accès au port 80 ou au DNS.",
"sslDocsLink": "Voir la documentation SSL",
"sslDomain": "Domaine",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Le nom de domaine public pour le certificat.",
"sslEmail": "E-mail",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Adresse e-mail de contact pour les notifications et le compte Let's Encrypt.",
"sslChallengeType": "Type de challenge",
"sslChallengeTypeDesc": "Méthode pour prouver la propriété du domaine à Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - nécessite le port 80 accessible depuis Internet",
"sslChallengeDns": "DNS (Cloudflare) - nécessite un jeton API Cloudflare",
"sslCloudflareToken": "Jeton API Cloudflare",
"sslCloudflareTokenPlaceholder": "Saisissez le jeton...",
"sslCloudflareTokenDesc": "Jeton limité avec l'autorisation Zone:DNS:Edit pour votre domaine.",
"sslCertStatus": "Statut du certificat",
"sslCertStatusNone": "Aucun certificat",
"sslCertStatusValid": "Valide",
"sslCertStatusExpiring": "Expiration prochaine",
"sslCertStatusExpired": "Expiré",
"sslCertExpiresAt": "Expire le {{date}}",
"sslLastIssued": "Dernière émission le {{date}}",
"sslRequestCert": "Émettre / Renouveler le certificat",
"sslRequestCertLoading": "Demande de certificat en cours...",
"sslRequestCertSuccess": "Certificat émis et installé avec succès",
"sslRequestCertFailed": "Échec de la demande de certificat",
"sslSave": "Enregistrer les paramètres",
"sslSaved": "Paramètres SSL enregistrés",
"sslSaveFailed": "Échec de l'enregistrement des paramètres SSL",
"sslRequiresDomain": "Le domaine et l'e-mail sont requis",
"sslInfoNote": "Après l'émission ou le chargement d'un certificat, Termix activera automatiquement le protocole HTTPS sur le port SSL (8443 par défaut), en plus du port HTTP existant. Assurez-vous que ce port est publié/associé à ce conteneur afin qu'il soit accessible.",
"sslManualOption": "Manuel (télécharger le certificat)",
"sslManualCert": "Certificat (PEM)",
"sslManualCertPlaceholder": "-----DÉBUT DU CERTIFICAT-----",
"sslManualKey": "Clé privée (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "שמירת הגדרות המיתוג נכשלה",
"sslDescription": "הנפקה וחידוש אוטומטית של תעודת SSL מהימנה מ-Let's Encrypt. דורשת גישת דומיין ציבורי ופורט 80 או גישת DNS.",
"sslDocsLink": "הצג מסמכי SSL",
"sslDomain": "תְחוּם",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "שם התחום הציבורי עבור התעודה.",
"sslEmail": "אֶלֶקטרוֹנִי",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "כתובת דוא\"ל ליצירת קשר עבור התראות וחשבון של Let's Encrypt.",
"sslChallengeType": "סוג האתגר",
"sslChallengeTypeDesc": "כיצד להוכיח בעלות על דומיין ל-Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - דורש פורט 80 נגיש מהאינטרנט",
"sslChallengeDns": "DNS (Cloudflare) - דורש אסימון API של Cloudflare",
"sslCloudflareToken": "אסימון API של Cloudflare",
"sslCloudflareTokenPlaceholder": "הזן אסימון...",
"sslCloudflareTokenDesc": "אסימון בטווח עם הרשאת עריכה של אזור:DNS: עבור הדומיין שלך.",
"sslCertStatus": "סטטוס התעודה",
"sslCertStatusNone": "אין תעודה",
"sslCertStatusValid": "תָקֵף",
"sslCertStatusExpiring": "פג תוקף בקרוב",
"sslCertStatusExpired": "פג תוקף",
"sslCertExpiresAt": "פג תוקף {{date}}",
"sslLastIssued": "הוצאה אחרונה {{date}}",
"sslRequestCert": "הנפקה / חידוש תעודה",
"sslRequestCertLoading": "מבקש תעודה...",
"sslRequestCertSuccess": "התעודה הונפקה והותקנה בהצלחה",
"sslRequestCertFailed": "בקשת האישור נכשלה",
"sslSave": "שמור הגדרות",
"sslSaved": "הגדרות SSL נשמרו",
"sslSaveFailed": "שמירת הגדרות SSL נכשלה",
"sslRequiresDomain": "נדרשים דומיין וכתובת אימייל",
"sslInfoNote": "לאחר הנפקה או העלאה של אישור, Termix יאפשר אוטומטית HTTPS ב-SSL_PORT (ברירת מחדל 8443) בנוסף לפורט HTTP הקיים. ודא שהפורט מפורסם/ממופה למכולה זו כך שניתן יהיה להגיע אליו.",
"sslManualOption": "מדריך (העלאת תעודה)",
"sslManualCert": "תעודה (PEM)",
"sslManualCertPlaceholder": "-----התחלת תעודה-----",
"sslManualKey": "מפתח פרטי (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "ब्रांडिंग सेटिंग्स सहेजने में विफल",
"sslDescription": "Let's Encrypt से एक विश्वसनीय SSL प्रमाणपत्र स्वचालित रूप से जारी करें और नवीनीकृत करें। इसके लिए सार्वजनिक डोमेन और पोर्ट 80 या DNS पहुँच आवश्यक है।",
"sslDocsLink": "SSL दस्तावेज़ देखें",
"sslDomain": "डोमेन",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "प्रमाणपत्र के लिए सार्वजनिक डोमेन नाम।",
"sslEmail": "ईमेल",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Let's Encrypt सूचनाओं और खाते के लिए संपर्क ईमेल।",
"sslChallengeType": "चुनौती प्रकार",
"sslChallengeTypeDesc": "Let's Encrypt को डोमेन स्वामित्व साबित करने का तरीका।",
"sslChallengeHttp": "HTTP (webroot) - इंटरनेट से पोर्ट 80 सुलभ होना आवश्यक है",
"sslChallengeDns": "DNS (Cloudflare) - Cloudflare API टोकन आवश्यक है",
"sslCloudflareToken": "Cloudflare API टोकन",
"sslCloudflareTokenPlaceholder": "टोकन दर्ज करें...",
"sslCloudflareTokenDesc": "आपके डोमेन के लिए Zone:DNS:Edit अनुमति वाला स्कोप्ड टोकन।",
"sslCertStatus": "प्रमाणपत्र स्थिति",
"sslCertStatusNone": "कोई प्रमाणपत्र नहीं",
"sslCertStatusValid": "मान्य",
"sslCertStatusExpiring": "जल्द समाप्त हो रहा है",
"sslCertStatusExpired": "समाप्त",
"sslCertExpiresAt": "{{date}} को समाप्त होगा",
"sslLastIssued": "अंतिम जारी: {{date}}",
"sslRequestCert": "प्रमाणपत्र जारी करें / नवीनीकरण करें",
"sslRequestCertLoading": "प्रमाणपत्र अनुरोध किया जा रहा है...",
"sslRequestCertSuccess": "प्रमाणपत्र सफलतापूर्वक जारी और स्थापित कर दिया गया",
"sslRequestCertFailed": "प्रमाणपत्र अनुरोध विफल",
"sslSave": "सेटिंग सहेजें",
"sslSaved": "SSL सेटिंग सहेजी गई",
"sslSaveFailed": "SSL सेटिंग सहेजने में विफल",
"sslRequiresDomain": "डोमेन और ईमेल आवश्यक हैं",
"sslInfoNote": "सर्टिफिकेट जारी करने या अपलोड करने के बाद, टर्मिक्स मौजूदा HTTP पोर्ट के अलावा SSL_PORT (डिफ़ॉल्ट 8443) पर HTTPS को स्वचालित रूप से सक्षम कर देगा। सुनिश्चित करें कि यह पोर्ट इस कंटेनर पर प्रकाशित/मैप किया गया है ताकि यह पहुंच योग्य हो।",
"sslManualOption": "मैन्युअल (प्रमाणपत्र अपलोड करें)",
"sslManualCert": "प्रमाणपत्र (पीईएम)",
"sslManualCertPlaceholder": "-----प्रमाणपत्र प्रारंभ-----",
"sslManualKey": "निजी कुंजी (पीईएम)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Nem sikerült menteni a márkabeállításokat",
"sslDescription": "Automatikusan kibocsát és megújít egy megbízható SSL-tanúsítványt a Let's Encrypt-től. Nyilvános domaint és 80-as portot vagy DNS-hozzáférést igényel.",
"sslDocsLink": "SSL-dokumentációk megtekintése",
"sslDomain": "Domain",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "A tanúsítvány nyilvános domainneve.",
"sslEmail": "Email",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Kapcsolattartási e-mail cím a Let's Encrypt értesítéseivel és fiókjával kapcsolatban.",
"sslChallengeType": "Kihívás típusa",
"sslChallengeTypeDesc": "Hogyan lehet igazolni a domain tulajdonjogát a Let's Encrypt számára?",
"sslChallengeHttp": "HTTP (webroot) – internetről elérhető 80-as portot igényel",
"sslChallengeDns": "DNS (Cloudflare) – Cloudflare API tokent igényel",
"sslCloudflareToken": "Cloudflare API token",
"sslCloudflareTokenPlaceholder": "Token megadása...",
"sslCloudflareTokenDesc": "Hatókörbe tartozó token Zóna:DNS:Szerkesztés engedéllyel a domainhez.",
"sslCertStatus": "Tanúsítvány állapota",
"sslCertStatusNone": "Nincs tanúsítvány",
"sslCertStatusValid": "Érvényes",
"sslCertStatusExpiring": "Hamarosan lejár",
"sslCertStatusExpired": "Lejárt",
"sslCertExpiresAt": "Lejár {{date}}",
"sslLastIssued": "Utolsó kiadás {{date}}",
"sslRequestCert": "Tanúsítvány kiállítása / megújítása",
"sslRequestCertLoading": "Tanúsítvány kérése...",
"sslRequestCertSuccess": "A tanúsítvány kiadva és sikeresen telepítve",
"sslRequestCertFailed": "Tanúsítványkérés sikertelen",
"sslSave": "Beállítások mentése",
"sslSaved": "SSL-beállítások mentve",
"sslSaveFailed": "Nem sikerült menteni az SSL-beállításokat",
"sslRequiresDomain": "Domain és email cím megadása kötelező",
"sslInfoNote": "Tanúsítvány kiállítása vagy feltöltése után a Termix automatikusan engedélyezi a HTTPS-t az SSL_PORT-on (alapértelmezett: 8443) a meglévő HTTP port mellett. Győződjön meg arról, hogy a port közzé van téve/hozzá van rendelve ehhez a konténerhez, hogy elérhető legyen.",
"sslManualOption": "Kézikönyv (tanúsítvány feltöltése)",
"sslManualCert": "Tanúsítvány (PEM)",
"sslManualCertPlaceholder": "-----TANÚSÍTVÁNY KEZDETE-----",
"sslManualKey": "Privát kulcs (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Gagal menyimpan pengaturan merek.",
"sslDescription": "Terbitkan dan perbarui sertifikat SSL tepercaya dari Let's Encrypt secara otomatis. Membutuhkan domain publik dan port 80 atau akses DNS.",
"sslDocsLink": "Lihat dokumentasi SSL",
"sslDomain": "Domain",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Nama domain publik untuk sertifikat tersebut.",
"sslEmail": "E-mail",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Alamat email kontak untuk notifikasi dan akun Let's Encrypt.",
"sslChallengeType": "Jenis Tantangan",
"sslChallengeTypeDesc": "Bagaimana cara membuktikan kepemilikan domain kepada Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - memerlukan port 80 yang dapat diakses dari internet.",
"sslChallengeDns": "DNS (Cloudflare) - memerlukan token API Cloudflare",
"sslCloudflareToken": "Token API Cloudflare",
"sslCloudflareTokenPlaceholder": "Masukkan token...",
"sslCloudflareTokenDesc": "Token terlingkup dengan izin Zone:DNS:Edit untuk domain Anda.",
"sslCertStatus": "Status Sertifikat",
"sslCertStatusNone": "Tidak ada sertifikat",
"sslCertStatusValid": "Sah",
"sslCertStatusExpiring": "Segera berakhir",
"sslCertStatusExpired": "Kedaluwarsa",
"sslCertExpiresAt": "Berakhir {{date}}",
"sslLastIssued": "Terakhir diterbitkan {{date}}",
"sslRequestCert": "Menerbitkan/Memperpanjang Sertifikat",
"sslRequestCertLoading": "Meminta sertifikat...",
"sslRequestCertSuccess": "Sertifikat telah diterbitkan dan diinstal dengan sukses.",
"sslRequestCertFailed": "Permintaan sertifikat gagal.",
"sslSave": "Simpan Pengaturan",
"sslSaved": "Pengaturan SSL tersimpan",
"sslSaveFailed": "Gagal menyimpan pengaturan SSL",
"sslRequiresDomain": "Nama domain dan email wajib diisi.",
"sslInfoNote": "Setelah menerbitkan atau mengunggah sertifikat, Termix akan secara otomatis mengaktifkan HTTPS pada SSL_PORT (default 8443) di samping port HTTP yang sudah ada. Pastikan port tersebut dipublikasikan/dipetakan ke kontainer ini agar dapat dijangkau.",
"sslManualOption": "Manual (unggah sertifikat)",
"sslManualCert": "Sertifikat (PEM)",
"sslManualCertPlaceholder": "-----MULAI SERTIFIKAT-----",
"sslManualKey": "Kunci Pribadi (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Impossibile salvare le impostazioni del marchio.",
"sslDescription": "Emettere e rinnovare automaticamente un certificato SSL attendibile da Let's Encrypt. Richiede un dominio pubblico e accesso alla porta 80 o al DNS.",
"sslDocsLink": "Visualizza documentazione SSL",
"sslDomain": "Dominio",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Il nome di dominio pubblico per il certificato.",
"sslEmail": "Email",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Email di contatto per notifiche e account Let's Encrypt.",
"sslChallengeType": "Tipo di challenge",
"sslChallengeTypeDesc": "Come dimostrare la proprietà del dominio a Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - richiede la porta 80 accessibile da internet",
"sslChallengeDns": "DNS (Cloudflare) - richiede un token API Cloudflare",
"sslCloudflareToken": "Token API Cloudflare",
"sslCloudflareTokenPlaceholder": "Inserisci token...",
"sslCloudflareTokenDesc": "Token con autorizzazione Zone:DNS:Edit per il tuo dominio.",
"sslCertStatus": "Stato del certificato",
"sslCertStatusNone": "Nessun certificato",
"sslCertStatusValid": "Valido",
"sslCertStatusExpiring": "In scadenza",
"sslCertStatusExpired": "Scaduto",
"sslCertExpiresAt": "Scade il {{date}}",
"sslLastIssued": "Ultimo rilascio il {{date}}",
"sslRequestCert": "Emetti / Rinnova certificato",
"sslRequestCertLoading": "Richiesta certificato in corso...",
"sslRequestCertSuccess": "Certificato emesso e installato con successo",
"sslRequestCertFailed": "Richiesta certificato fallita",
"sslSave": "Salva impostazioni",
"sslSaved": "Impostazioni SSL salvate",
"sslSaveFailed": "Salvataggio impostazioni SSL fallito",
"sslRequiresDomain": "Dominio ed email sono obbligatori",
"sslInfoNote": "Dopo aver emesso o caricato un certificato, Termix abiliterà automaticamente HTTPS sulla porta SSL_PORT (predefinita 8443) oltre alla porta HTTP esistente. Assicurarsi che tale porta sia pubblicata/mappata a questo container in modo che sia raggiungibile.",
"sslManualOption": "Manuale (caricamento del certificato)",
"sslManualCert": "Certificato (PEM)",
"sslManualCertPlaceholder": "-----INIZIO CERTIFICATO-----",
"sslManualKey": "Chiave privata (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "ブランド設定の保存に失敗しました",
"sslDescription": "Let's Encryptから信頼できるSSL証明書を自動的に発行・更新します。公開ドメインとポート80、またはDNSアクセスが必要です。",
"sslDocsLink": "SSLドキュメントを表示",
"sslDomain": "ドメイン",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "証明書の公開ドメイン名。",
"sslEmail": "メールアドレス",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Let's Encryptの通知およびアカウント用の連絡先メールアドレス。",
"sslChallengeType": "チャレンジタイプ",
"sslChallengeTypeDesc": "Let's Encryptにドメイン所有権を証明する方法。",
"sslChallengeHttp": "HTTP(webroot)- インターネットからポート80にアクセスできる必要があります",
"sslChallengeDns": "DNS(Cloudflare)- Cloudflare APIトークンが必要です",
"sslCloudflareToken": "Cloudflare APIトークン",
"sslCloudflareTokenPlaceholder": "トークンを入力...",
"sslCloudflareTokenDesc": "ドメインに対するZone:DNS:Edit権限を持つスコープ付きトークン。",
"sslCertStatus": "証明書ステータス",
"sslCertStatusNone": "証明書なし",
"sslCertStatusValid": "有効",
"sslCertStatusExpiring": "間もなく期限切れ",
"sslCertStatusExpired": "期限切れ",
"sslCertExpiresAt": "有効期限 {{date}}",
"sslLastIssued": "最終発行日 {{date}}",
"sslRequestCert": "証明書を発行/更新",
"sslRequestCertLoading": "証明書をリクエスト中...",
"sslRequestCertSuccess": "証明書が正常に発行され、インストールされました",
"sslRequestCertFailed": "証明書リクエストに失敗しました",
"sslSave": "設定を保存",
"sslSaved": "SSL設定が保存されました",
"sslSaveFailed": "SSL設定の保存に失敗しました",
"sslRequiresDomain": "ドメインとメールアドレスが必要です",
"sslInfoNote": "証明書の発行またはアップロード後、Termixは既存のHTTPポートに加えて、SSL_PORT(デフォルトは8443)でHTTPSを自動的に有効にします。このポートがコンテナに公開/マッピングされ、アクセス可能であることを確認してください。",
"sslManualOption": "手動(証明書のアップロード)",
"sslManualCert": "証明書(PEM)",
"sslManualCertPlaceholder": "-----証明書開始-----",
"sslManualKey": "秘密鍵(PEM形式)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "브랜딩 설정을 저장하는 데 실패했습니다.",
"sslDescription": "Let's Encrypt에서 신뢰할 수 있는 SSL 인증서를 자동으로 발급 및 갱신합니다. 공개 도메인과 포트 80 또는 DNS 액세스가 필요합니다.",
"sslDocsLink": "SSL 문서 보기",
"sslDomain": "도메인",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "인증서에 사용할 공개 도메인 이름입니다.",
"sslEmail": "이메일",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Let's Encrypt 알림 및 계정용 연락처 이메일입니다.",
"sslChallengeType": "인증 방식",
"sslChallengeTypeDesc": "Let's Encrypt에 도메인 소유권을 증명하는 방법입니다.",
"sslChallengeHttp": "HTTP(webroot) - 인터넷에서 포트 80에 액세스할 수 있어야 함",
"sslChallengeDns": "DNS(Cloudflare) - Cloudflare API 토큰 필요",
"sslCloudflareToken": "Cloudflare API 토큰",
"sslCloudflareTokenPlaceholder": "토큰 입력...",
"sslCloudflareTokenDesc": "도메인에 대한 Zone:DNS:Edit 권한이 있는 범위 지정 토큰입니다.",
"sslCertStatus": "인증서 상태",
"sslCertStatusNone": "인증서 없음",
"sslCertStatusValid": "유효함",
"sslCertStatusExpiring": "곧 만료됨",
"sslCertStatusExpired": "만료됨",
"sslCertExpiresAt": "만료일: {{date}}",
"sslLastIssued": "마지막 발급일: {{date}}",
"sslRequestCert": "인증서 발급 / 갱신",
"sslRequestCertLoading": "인증서 요청 중...",
"sslRequestCertSuccess": "인증서가 성공적으로 발급 및 설치되었습니다.",
"sslRequestCertFailed": "인증서 요청에 실패했습니다.",
"sslSave": "설정 저장",
"sslSaved": "SSL 설정이 저장되었습니다.",
"sslSaveFailed": "SSL 설정 저장에 실패했습니다.",
"sslRequiresDomain": "도메인과 이메일은 필수입니다.",
"sslInfoNote": "인증서를 발급하거나 업로드하면 Termix는 기존 HTTP 포트 외에도 SSL_PORT(기본값 8443)에서 HTTPS를 자동으로 활성화합니다. 해당 포트가 컨테이너에 접근 가능하도록 게시/매핑되어 있는지 확인하십시오.",
"sslManualOption": "수동 업로드 (인증서 업로드)",
"sslManualCert": "인증서(PEM)",
"sslManualCertPlaceholder": "-----시작 인증서-----",
"sslManualKey": "개인 키(PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Het opslaan van de merkinstellingen is mislukt.",
"sslDescription": "Automatisch een vertrouwd SSL-certificaat van Let's Encrypt aanvragen en verlengen. Vereist een publiek domein en toegang tot poort 80 of DNS.",
"sslDocsLink": "Bekijk de SSL-documentatie",
"sslDomain": "Domein",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "De publieke domeinnaam voor het certificaat.",
"sslEmail": "E-mail",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Contact-e-mailadres voor Let's Encrypt-meldingen en accountinformatie.",
"sslChallengeType": "Uitdagingstype",
"sslChallengeTypeDesc": "Hoe bewijs je domeineigendom aan Let's Encrypt?",
"sslChallengeHttp": "HTTP (webroot) - vereist poort 80 die toegankelijk is vanaf internet.",
"sslChallengeDns": "DNS (Cloudflare) - vereist een Cloudflare API-token",
"sslCloudflareToken": "Cloudflare API-token",
"sslCloudflareTokenPlaceholder": "Voer token in...",
"sslCloudflareTokenDesc": "Een scoped token met Zone:DNS:Edit-machtiging voor uw domein.",
"sslCertStatus": "Certificaatstatus",
"sslCertStatusNone": "Geen certificaat",
"sslCertStatusValid": "Geldig",
"sslCertStatusExpiring": "Verloopt binnenkort",
"sslCertStatusExpired": "Verlopen",
"sslCertExpiresAt": "Verloopt {{date}}",
"sslLastIssued": "Laatst uitgegeven {{date}}",
"sslRequestCert": "Certificaat aanvragen/verlengen",
"sslRequestCertLoading": "Certificaat aanvragen...",
"sslRequestCertSuccess": "Certificaat uitgegeven en succesvol geïnstalleerd",
"sslRequestCertFailed": "Certificaatverzoek mislukt",
"sslSave": "Instellingen opslaan",
"sslSaved": "SSL-instellingen opgeslagen",
"sslSaveFailed": "Het opslaan van de SSL-instellingen is mislukt.",
"sslRequiresDomain": "Domein en e-mailadres zijn verplicht.",
"sslInfoNote": "Na het uitgeven of uploaden van een certificaat zal Termix automatisch HTTPS inschakelen op SSL_PORT (standaard 8443) naast de bestaande HTTP-poort. Zorg ervoor dat deze poort is gepubliceerd/toegewezen aan deze container, zodat deze bereikbaar is.",
"sslManualOption": "Handmatig (certificaat uploaden)",
"sslManualCert": "Certificaat (PEM)",
"sslManualCertPlaceholder": "-----BEGIN CERTIFICAAT-----",
"sslManualKey": "Privésleutel (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Kunne ikke lagre merkevareinnstillingene",
"sslDescription": "Utsted og forny automatisk et klarert SSL-sertifikat fra Let's Encrypt. Krever et offentlig domene og port 80 eller DNS-tilgang.",
"sslDocsLink": "Vis SSL-dokumenter",
"sslDomain": "Domene",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Det offentlige domenenavnet for sertifikatet.",
"sslEmail": "E-post",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Kontakt-e-postadresse for Let's Encrypt-varsler og konto.",
"sslChallengeType": "Utfordringstype",
"sslChallengeTypeDesc": "Slik beviser du domeneeierskap til Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) – krever port 80 tilgjengelig fra internett",
"sslChallengeDns": "DNS (Cloudflare) – krever et Cloudflare API-token",
"sslCloudflareToken": "Cloudflare API-token",
"sslCloudflareTokenPlaceholder": "Skriv inn token...",
"sslCloudflareTokenDesc": "Scope-token med Zone:DNS:Rediger-tillatelse for domenet ditt.",
"sslCertStatus": "Sertifikatstatus",
"sslCertStatusNone": "Ingen sertifikat",
"sslCertStatusValid": "Gyldig",
"sslCertStatusExpiring": "Utløper snart",
"sslCertStatusExpired": "Utløpt",
"sslCertExpiresAt": "Utløper {{date}}",
"sslLastIssued": "Sist utstedt {{date}}",
"sslRequestCert": "Utstede/fornye sertifikat",
"sslRequestCertLoading": "Be om sertifikat...",
"sslRequestCertSuccess": "Sertifikatet er utstedt og installert",
"sslRequestCertFailed": "Sertifikatforespørsel mislyktes",
"sslSave": "Lagre innstillinger",
"sslSaved": "SSL-innstillinger lagret",
"sslSaveFailed": "Kunne ikke lagre SSL-innstillingene",
"sslRequiresDomain": "Domene og e-post er obligatorisk",
"sslInfoNote": "Etter utstedelse eller opplasting av et sertifikat, vil Termix automatisk aktivere HTTPS på SSL_PORT (standard 8443) i tillegg til den eksisterende HTTP-porten. Sørg for at porten er publisert/tilordnet til denne containeren slik at den er tilgjengelig.",
"sslManualOption": "Manual (last opp sertifikat)",
"sslManualCert": "Sertifikat (PEM)",
"sslManualCertPlaceholder": "-----STARTSERTIFIKAT-----",
"sslManualKey": "Privat nøkkel (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Nie udało się zapisać ustawień marki",
"sslDescription": "Automatycznie wystawiaj i odnawiaj zaufany certyfikat SSL od Let's Encrypt. Wymaga domeny publicznej i portu 80 lub dostępu DNS.",
"sslDocsLink": "Zobacz dokumentację SSL",
"sslDomain": "Domena",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Publiczna nazwa domeny certyfikatu.",
"sslEmail": "E-mail",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Adres e-mail kontaktowy do powiadomień i konta Let's Encrypt.",
"sslChallengeType": "Typ wyzwania",
"sslChallengeTypeDesc": "Jak udowodnić Let's Encrypt własność domeny.",
"sslChallengeHttp": "HTTP (webroot) - wymaga portu 80 dostępnego z internetu",
"sslChallengeDns": "DNS (Cloudflare) - wymaga tokenu API Cloudflare",
"sslCloudflareToken": "Token API Cloudflare",
"sslCloudflareTokenPlaceholder": "Wprowadź token...",
"sslCloudflareTokenDesc": "Token zakresowy z uprawnieniem Zone:DNS:Edit dla Twojej domeny.",
"sslCertStatus": "Status certyfikatu",
"sslCertStatusNone": "Brak certyfikatu",
"sslCertStatusValid": "Ważny",
"sslCertStatusExpiring": "Wkrótce wygasa",
"sslCertStatusExpired": "Wygasły",
"sslCertExpiresAt": "Wygasa {{date}}",
"sslLastIssued": "Ostatnio wystawiono {{date}}",
"sslRequestCert": "Wystaw / odnów certyfikat",
"sslRequestCertLoading": "Żądanie certyfikatu...",
"sslRequestCertSuccess": "Pomyślnie wystawiono i zainstalowano certyfikat",
"sslRequestCertFailed": "Żądanie certyfikatu nie powiodło się",
"sslSave": "Zapisz ustawienia",
"sslSaved": "Ustawienia SSL zapisane",
"sslSaveFailed": "Nie udało się zapisać ustawień SSL",
"sslRequiresDomain": "Wymagane są domena i adres e-mail",
"sslInfoNote": "Po wystawieniu lub przesłaniu certyfikatu, Termix automatycznie włączy protokół HTTPS na porcie SSL_PORT (domyślnie 8443) oprócz istniejącego portu HTTP. Upewnij się, że port ten jest opublikowany/zamapowany w tym kontenerze, aby był osiągalny.",
"sslManualOption": "Ręcznie (prześlij certyfikat)",
"sslManualCert": "Certyfikat (PEM)",
"sslManualCertPlaceholder": "-----BEGIN CERTIFICATE-----",
"sslManualKey": "Klucz prywatny (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Falha ao salvar as configurações de marca",
"sslDescription": "Emita e renove automaticamente um certificado SSL confiável da Let's Encrypt. Requer um domínio público e acesso à porta 80 ou DNS.",
"sslDocsLink": "Ver documentação SSL",
"sslDomain": "Domínio",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "O nome de domínio público do certificado.",
"sslEmail": "E-mail",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "E-mail de contato para notificações e conta do Let's Encrypt.",
"sslChallengeType": "Tipo de desafio",
"sslChallengeTypeDesc": "Como comprovar a propriedade de um domínio para o Let's Encrypt.",
"sslChallengeHttp": "HTTP (raiz web) - requer que a porta 80 esteja acessível pela internet.",
"sslChallengeDns": "DNS (Cloudflare) - requer um token de API do Cloudflare",
"sslCloudflareToken": "Token da API do Cloudflare",
"sslCloudflareTokenPlaceholder": "Insira o token...",
"sslCloudflareTokenDesc": "Token com escopo definido e permissão Zone:DNS:Edit para o seu domínio.",
"sslCertStatus": "Status do certificado",
"sslCertStatusNone": "Sem certificado",
"sslCertStatusValid": "Válido",
"sslCertStatusExpiring": "Expira em breve",
"sslCertStatusExpired": "Expirado",
"sslCertExpiresAt": "Expira em {{date}}",
"sslLastIssued": "Última edição {{date}}",
"sslRequestCert": "Emitir/Renovar Certificado",
"sslRequestCertLoading": "Solicitar certificado...",
"sslRequestCertSuccess": "Certificado emitido e instalado com sucesso.",
"sslRequestCertFailed": "A solicitação de certificado falhou",
"sslSave": "Salvar configurações",
"sslSaved": "Configurações SSL salvas",
"sslSaveFailed": "Falha ao salvar as configurações SSL",
"sslRequiresDomain": "Domínio e e-mail são obrigatórios.",
"sslInfoNote": "Após a emissão ou o carregamento de um certificado, o Termix habilitará automaticamente o HTTPS na porta SSL_PORT (padrão 8443), além da porta HTTP existente. Certifique-se de que essa porta esteja publicada/mapeada para este contêiner, de forma que esteja acessível.",
"sslManualOption": "Manual (envio do certificado)",
"sslManualCert": "Certificado (PEM)",
"sslManualCertPlaceholder": "-----INÍCIO DO CERTIFICADO-----",
"sslManualKey": "Chave privada (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Não foi possível guardar as definições de marca.",
"sslDescription": "Emitir e renovar automaticamente um certificado SSL confiável da Let's Encrypt. Requer um domínio público e acesso à porta 80 ou DNS.",
"sslDocsLink": "Ver documentação SSL",
"sslDomain": "Domínio",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "O nome de domínio público para o certificado.",
"sslEmail": "E-mail",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "E-mail de contacto para notificações e conta Let's Encrypt.",
"sslChallengeType": "Tipo de desafio",
"sslChallengeTypeDesc": "Como provar a propriedade do domínio à Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - requer que a porta 80 esteja acessível a partir da internet",
"sslChallengeDns": "DNS (Cloudflare) - requer um token de API da Cloudflare",
"sslCloudflareToken": "Token de API da Cloudflare",
"sslCloudflareTokenPlaceholder": "Introduzir token...",
"sslCloudflareTokenDesc": "Token com âmbito e permissão Zone:DNS:Edit para o seu domínio.",
"sslCertStatus": "Estado do certificado",
"sslCertStatusNone": "Sem certificado",
"sslCertStatusValid": "Válido",
"sslCertStatusExpiring": "A expirar em breve",
"sslCertStatusExpired": "Expirado",
"sslCertExpiresAt": "Expira a {{date}}",
"sslLastIssued": "Última emissão a {{date}}",
"sslRequestCert": "Emitir / Renovar certificado",
"sslRequestCertLoading": "A solicitar certificado...",
"sslRequestCertSuccess": "Certificado emitido e instalado com sucesso",
"sslRequestCertFailed": "Pedido de certificado falhou",
"sslSave": "Guardar definições",
"sslSaved": "Definições SSL guardadas",
"sslSaveFailed": "Falha ao guardar definições SSL",
"sslRequiresDomain": "Domínio e e-mail são obrigatórios",
"sslInfoNote": "Após a emissão ou o carregamento de um certificado, o Termix irá ativar automaticamente o HTTPS na porta SSL_PORT (padrão 8443), para além da porta HTTP existente. Certifique-se de que esta porta está publicada/mapeada para este contentor, de forma a que esteja acessível.",
"sslManualOption": "Manual (envio do certificado)",
"sslManualCert": "Certificado (PEM)",
"sslManualCertPlaceholder": "-----INÍCIO DO CERTIFICADO-----",
"sslManualKey": "Chave privada (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Setările de branding nu au putut fi salvate",
"sslDescription": "Emite și reînnoiește automat un certificat SSL de încredere de la Let's Encrypt. Necesită un domeniu public și portul 80 sau acces DNS.",
"sslDocsLink": "Vizualizați documentele SSL",
"sslDomain": "Domeniu",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Numele de domeniu public pentru certificat.",
"sslEmail": "E-mail",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Adresă de e-mail de contact pentru notificările și contul Let's Encrypt.",
"sslChallengeType": "Tipul provocării",
"sslChallengeTypeDesc": "Cum să dovedești dreptul de proprietate asupra domeniului către Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - necesită portul 80 accesibil de pe internet",
"sslChallengeDns": "DNS (Cloudflare) - necesită un token API Cloudflare",
"sslCloudflareToken": "Tokenul API Cloudflare",
"sslCloudflareTokenPlaceholder": "Introduceți tokenul...",
"sslCloudflareTokenDesc": "Token cu scop specific și permisiune Zone:DNS:Edit pentru domeniul dvs.",
"sslCertStatus": "Starea certificatului",
"sslCertStatusNone": "Fără certificat",
"sslCertStatusValid": "Valabil",
"sslCertStatusExpiring": "Expiră în curând",
"sslCertStatusExpired": "Expirat",
"sslCertExpiresAt": "Expiră la {{date}}",
"sslLastIssued": "Ultima emisiune {{date}}",
"sslRequestCert": "Emitere / Reînnoire certificat",
"sslRequestCertLoading": "Se solicită certificatul...",
"sslRequestCertSuccess": "Certificat emis și instalat cu succes",
"sslRequestCertFailed": "Solicitarea de certificat a eșuat",
"sslSave": "Salvați setările",
"sslSaved": "Setările SSL au fost salvate",
"sslSaveFailed": "Setările SSL nu au putut fi salvate.",
"sslRequiresDomain": "Domeniul și adresa de e-mail sunt obligatorii",
"sslInfoNote": "După emiterea sau încărcarea unui certificat, Termix va activa automat HTTPS pe SSL_PORT (implicit 8443), pe lângă portul HTTP existent. Asigurați-vă că portul este publicat/mapat la acest container, astfel încât să fie accesibil.",
"sslManualOption": "Manual (încărcare certificat)",
"sslManualCert": "Certificat (PEM)",
"sslManualCertPlaceholder": "-----ÎNCEPERE CERTIFICAT-----",
"sslManualKey": "Cheie privată (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Не удалось сохранить настройки брендинга.",
"sslDescription": "Автоматически выпускать и обновлять доверенный SSL-сертификат от Let’s Encrypt. Требуется общедоступный домен и доступ к порту 80 или DNS.",
"sslDocsLink": "Документация SSL",
"sslDomain": "Домен",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Общедоступное доменное имя для сертификата.",
"sslEmail": "Email",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Контактный email для уведомлений Let's Encrypt и учетной записи.",
"sslChallengeType": "Тип проверки",
"sslChallengeTypeDesc": "Способ подтверждения владения доменом для Let’s Encrypt.",
"sslChallengeHttp": "HTTP (webroot) — требуется доступ к порту 80 из интернета",
"sslChallengeDns": "DNS (Cloudflare) — требуется API-токен Cloudflare",
"sslCloudflareToken": "API-токен Cloudflare",
"sslCloudflareTokenPlaceholder": "Введите токен...",
"sslCloudflareTokenDesc": "Токен с ограниченной областью действия и разрешением Zone:DNS:Edit для вашего домена.",
"sslCertStatus": "Статус сертификата",
"sslCertStatusNone": "Сертификат отсутствует",
"sslCertStatusValid": "Действительный",
"sslCertStatusExpiring": "Скоро истекает",
"sslCertStatusExpired": "Истек",
"sslCertExpiresAt": "Истекает {{date}}",
"sslLastIssued": "Последний выпуск {{date}}",
"sslRequestCert": "Выпустить / Обновить сертификат",
"sslRequestCertLoading": "Запрос сертификата...",
"sslRequestCertSuccess": "Сертификат успешно выпущен и установлен",
"sslRequestCertFailed": "Ошибка запроса сертификата",
"sslSave": "Сохранить настройки",
"sslSaved": "Настройки SSL сохранены",
"sslSaveFailed": "Не удалось сохранить настройки SSL",
"sslRequiresDomain": "Требуются домен и email",
"sslInfoNote": "После выдачи или загрузки сертификата Termix автоматически включит HTTPS на SSL_PORT (по умолчанию 8443) в дополнение к существующему HTTP-порту. Убедитесь, что этот порт опубликован/сопоставлен с данным контейнером, чтобы он был доступен.",
"sslManualOption": "Ручная загрузка сертификата",
"sslManualCert": "Сертификат (PEM)",
"sslManualCertPlaceholder": "-----НАЧАЛО ПОЛУЧЕНИЯ СЕРТИФИКАТА-----",
"sslManualKey": "Закрытый ключ (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Чување подешавања брендирања није успело",
"sslDescription": "Аутоматски издајте и обновите поуздани SSL сертификат од Let's Encrypt. Потребан је јавни домен и порт 80 или DNS приступ.",
"sslDocsLink": "Погледајте SSL документацију",
"sslDomain": "Домен",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Јавно име домена за сертификат.",
"sslEmail": "Е-пошта",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Контакт имејл за обавештења и налог од Let's Encrypt-а.",
"sslChallengeType": "Тип изазова",
"sslChallengeTypeDesc": "Како доказати власништво над доменом компанији Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - захтева порт 80 доступан са интернета",
"sslChallengeDns": "DNS (Cloudflare) - захтева Cloudflare API токен",
"sslCloudflareToken": "Клаудфлејр АПИ токен",
"sslCloudflareTokenPlaceholder": "Унесите токен...",
"sslCloudflareTokenDesc": "Ограничени токен са дозволом Zone:DNS:Edit за ваш домен.",
"sslCertStatus": "Статус сертификата",
"sslCertStatusNone": "Без сертификата",
"sslCertStatusValid": "Важи",
"sslCertStatusExpiring": "Ускоро истиче",
"sslCertStatusExpired": "Истекло",
"sslCertExpiresAt": "Истиче {{date}}",
"sslLastIssued": "Последње издање {{date}}",
"sslRequestCert": "Издавање / обнављање сертификата",
"sslRequestCertLoading": "Захтева се сертификат...",
"sslRequestCertSuccess": "Сертификат издат и успешно инсталиран",
"sslRequestCertFailed": "Захтев за сертификат није успео",
"sslSave": "Сачувај подешавања",
"sslSaved": "SSL подешавања су сачувана",
"sslSaveFailed": "Чување SSL подешавања није успело",
"sslRequiresDomain": "Домен и имејл су обавезни",
"sslInfoNote": "Након издавања или отпремања сертификата, Termix ће аутоматски омогућити HTTPS на SSL_PORT (подразумевано 8443) поред постојећег HTTP порта. Уверите се да је тај порт објављен/мапиран на овај контејнер како би био доступан.",
"sslManualOption": "Ручно (отпремање сертификата)",
"sslManualCert": "Сертификат (ПЕМ)",
"sslManualCertPlaceholder": "-----ПОЧЕТАК СЕРТИФИКАТА-----",
"sslManualKey": "Приватни кључ (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Det gick inte att spara varumärkesinställningarna",
"sslDescription": "Utfärda och förnya automatiskt ett betrott SSL-certifikat från Let's Encrypt. Kräver en allmän domän och port 80 eller DNS-åtkomst.",
"sslDocsLink": "Visa SSL-dokument",
"sslDomain": "Domän",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Det offentliga domännamnet för certifikatet.",
"sslEmail": "E-post",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Kontakta e-postadressen för Let's Encrypt-aviseringar och konto.",
"sslChallengeType": "Utmaningstyp",
"sslChallengeTypeDesc": "Hur man bevisar domänägande för Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - kräver port 80 åtkomlig från internet",
"sslChallengeDns": "DNS (Cloudflare) – kräver en Cloudflare API-token",
"sslCloudflareToken": "Cloudflare API-token",
"sslCloudflareTokenPlaceholder": "Ange token...",
"sslCloudflareTokenDesc": "Scoped-token med Zone:DNS:Edit-behörighet för din domän.",
"sslCertStatus": "Certifikatstatus",
"sslCertStatusNone": "Inget certifikat",
"sslCertStatusValid": "Giltig",
"sslCertStatusExpiring": "Utgår snart",
"sslCertStatusExpired": "Utgången",
"sslCertExpiresAt": "Utgångsdatum {{date}}",
"sslLastIssued": "Senast utfärdad {{date}}",
"sslRequestCert": "Utfärda/förnya certifikat",
"sslRequestCertLoading": "Begär certifikat...",
"sslRequestCertSuccess": "Certifikat utfärdat och installerat",
"sslRequestCertFailed": "Certifikatbegäran misslyckades",
"sslSave": "Spara inställningar",
"sslSaved": "SSL-inställningar sparade",
"sslSaveFailed": "Kunde inte spara SSL-inställningarna",
"sslRequiresDomain": "Domän och e-postadress krävs",
"sslInfoNote": "Efter att ett certifikat utfärdats eller laddats upp kommer Termix automatiskt att aktivera HTTPS på SSL_PORT (standard 8443) utöver den befintliga HTTP-porten. Se till att porten är publicerad/mappad till den här containern så att den är nåbar.",
"sslManualOption": "Manual (ladda upp certifikat)",
"sslManualCert": "Certifikat (PEM)",
"sslManualCertPlaceholder": "-----BEGIN CERTIFIKAT-----",
"sslManualKey": "Privat nyckel (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "ไม่สามารถบันทึกการตั้งค่าแบรนด์ได้",
"sslDescription": "ออกและต่ออายุใบรับรอง SSL ที่เชื่อถือได้จาก Let's Encrypt โดยอัตโนมัติ ต้องใช้โดเมนสาธารณะและพอร์ต 80 หรือการเข้าถึง DNS",
"sslDocsLink": "ดูเอกสาร SSL",
"sslDomain": "โดเมน",
"sslDomainPlaceholder": "เทอร์มิกซ์.ตัวอย่าง.com",
"sslDomainDesc": "ชื่อโดเมนสาธารณะสำหรับใบรับรอง",
"sslEmail": "อีเมล",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "อีเมลสำหรับติดต่อเพื่อรับการแจ้งเตือนและข้อมูลเกี่ยวกับบัญชี Let's Encrypt",
"sslChallengeType": "ประเภทความท้าทาย",
"sslChallengeTypeDesc": "วิธีพิสูจน์ความเป็นเจ้าของโดเมนต่อ Let's Encrypt",
"sslChallengeHttp": "HTTP (webroot) - ต้องใช้พอร์ต 80 ที่สามารถเข้าถึงได้จากอินเทอร์เน็ต",
"sslChallengeDns": "DNS (Cloudflare) - ต้องใช้โทเค็น API ของ Cloudflare",
"sslCloudflareToken": "โทเค็น API ของ Cloudflare",
"sslCloudflareTokenPlaceholder": "ป้อนรหัส...",
"sslCloudflareTokenDesc": "โทเค็นแบบจำกัดขอบเขตพร้อมสิทธิ์ Zone:DNS:Edit สำหรับโดเมนของคุณ",
"sslCertStatus": "สถานะใบรับรอง",
"sslCertStatusNone": "ไม่มีใบรับรอง",
"sslCertStatusValid": "ถูกต้อง",
"sslCertStatusExpiring": "กำลังจะหมดอายุ",
"sslCertStatusExpired": "หมดอายุ",
"sslCertExpiresAt": "หมดอายุ {{date}}",
"sslLastIssued": "ออกครั้งล่าสุด {{date}}",
"sslRequestCert": "ออก/ต่ออายุใบรับรอง",
"sslRequestCertLoading": "ขอใบรับรอง...",
"sslRequestCertSuccess": "ออกและติดตั้งใบรับรองเรียบร้อยแล้ว",
"sslRequestCertFailed": "การขอใบรับรองล้มเหลว",
"sslSave": "บันทึกการตั้งค่า",
"sslSaved": "บันทึกการตั้งค่า SSL แล้ว",
"sslSaveFailed": "ไม่สามารถบันทึกการตั้งค่า SSL ได้",
"sslRequiresDomain": "ต้องระบุโดเมนและอีเมล",
"sslInfoNote": "หลังจากออกหรืออัปโหลดใบรับรองแล้ว Termix จะเปิดใช้งาน HTTPS บน SSL_PORT (ค่าเริ่มต้นคือ 8443) โดยอัตโนมัติ นอกเหนือจากพอร์ต HTTP ที่มีอยู่ โปรดตรวจสอบให้แน่ใจว่าพอร์ตดังกล่าวได้รับการเผยแพร่/แมปไปยังคอนเทนเนอร์นี้แล้ว เพื่อให้สามารถเข้าถึงได้",
"sslManualOption": "คู่มือ (อัปโหลดใบรับรอง)",
"sslManualCert": "ใบรับรอง (PEM)",
"sslManualCertPlaceholder": "-----เริ่มต้นใบรับรอง-----",
"sslManualKey": "กุญแจส่วนตัว (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Markalama ayarları kaydedilemedi.",
"sslDescription": "Let's Encrypt'ten güvenilir bir SSL sertifikasını otomatik olarak alın ve yenileyin. Genel bir alan adı ve 80 numaralı bağlantı noktası veya DNS erişimi gerektirir.",
"sslDocsLink": "SSL belgelerini görüntüle",
"sslDomain": "Alan Adı",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Sertifika için genel alan adı.",
"sslEmail": "E-posta",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Let's Encrypt bildirimleri ve hesabı için iletişim e-postası.",
"sslChallengeType": "Doğrulama Türü",
"sslChallengeTypeDesc": "Let's Encrypt'e alan adı sahipliğini kanıtlama yöntemi.",
"sslChallengeHttp": "HTTP (webroot) - 80 numaralı bağlantı noktasının internetten erişilebilir olmasını gerektirir",
"sslChallengeDns": "DNS (Cloudflare) - bir Cloudflare API belirteci gerektirir",
"sslCloudflareToken": "Cloudflare API Belirteci",
"sslCloudflareTokenPlaceholder": "Belirteci girin...",
"sslCloudflareTokenDesc": "Alan adınız için Zone:DNS:Edit iznine sahip kapsamlı belirteç.",
"sslCertStatus": "Sertifika Durumu",
"sslCertStatusNone": "Sertifika yok",
"sslCertStatusValid": "Geçerli",
"sslCertStatusExpiring": "Yakında süresi dolacak",
"sslCertStatusExpired": "Süresi dolmuş",
"sslCertExpiresAt": "Son kullanma: {{date}}",
"sslLastIssued": "Son veriliş: {{date}}",
"sslRequestCert": "Sertifika Ver / Yenile",
"sslRequestCertLoading": "Sertifika isteniyor...",
"sslRequestCertSuccess": "Sertifika başarıyla verildi ve kuruldu",
"sslRequestCertFailed": "Sertifika talebi başarısız oldu",
"sslSave": "Ayarları Kaydet",
"sslSaved": "SSL ayarları kaydedildi",
"sslSaveFailed": "SSL ayarları kaydedilemedi",
"sslRequiresDomain": "Alan adı ve e-posta gereklidir",
"sslInfoNote": "Sertifika oluşturulduktan veya yüklendikten sonra, Termix mevcut HTTP portuna ek olarak SSL_PORT (varsayılan 8443) üzerinden HTTPS'yi otomatik olarak etkinleştirecektir. Bu portun erişilebilir olması için bu konteynere yayınlandığından/eşlendiğinden emin olun.",
"sslManualOption": "Manuel (sertifika yükleme)",
"sslManualCert": "Sertifika (PEM)",
"sslManualCertPlaceholder": "-----SERTİFİKA BAŞLANGICI-----",
"sslManualKey": "Özel Anahtar (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Не вдалося зберегти налаштування брендингу",
"sslDescription": "Автоматично видавати та поновлювати довірений SSL-сертифікат від Let's Encrypt. Потрібен публічний домен і порт 80 або доступ до DNS.",
"sslDocsLink": "Переглянути документацію SSL",
"sslDomain": "Домен",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Ім'я публічного домену для сертифіката.",
"sslEmail": "Електронна пошта",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Контактна електронна адреса для сповіщень та облікового запису Let's Encrypt.",
"sslChallengeType": "Тип виклику",
"sslChallengeTypeDesc": "Як довести право власності на домен для Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) – потрібен порт 80, доступний з Інтернету",
"sslChallengeDns": "DNS (Cloudflare) – потрібен токен API Cloudflare",
"sslCloudflareToken": "Токен API Cloudflare",
"sslCloudflareTokenPlaceholder": "Введіть токен...",
"sslCloudflareTokenDesc": "Токен з обмеженою областю дії та дозволом Zone:DNS:Edit для вашого домену.",
"sslCertStatus": "Статус сертифіката",
"sslCertStatusNone": "Без сертифіката",
"sslCertStatusValid": "Дійсний",
"sslCertStatusExpiring": "Термін дії скоро закінчується",
"sslCertStatusExpired": "Термін дії минув",
"sslCertExpiresAt": "Термін дії закінчується {{date}}",
"sslLastIssued": "Останній виданий {{date}}",
"sslRequestCert": "Видача / поновлення сертифіката",
"sslRequestCertLoading": "Запит сертифіката...",
"sslRequestCertSuccess": "Сертифікат видано та встановлено успішно",
"sslRequestCertFailed": "Запит на сертифікат не вдалося",
"sslSave": "Зберегти налаштування",
"sslSaved": "Налаштування SSL збережено",
"sslSaveFailed": "Не вдалося зберегти налаштування SSL",
"sslRequiresDomain": "Домен та електронна адреса обов'язкові",
"sslInfoNote": "Після видачі або завантаження сертифіката Termix автоматично ввімкне HTTPS на SSL_PORT (за замовчуванням 8443) на додаток до існуючого HTTP-порту. Переконайтеся, що цей порт опубліковано/зіставлено з цим контейнером, щоб він був доступним.",
"sslManualOption": "Інструкція (сертифікат завантаження)",
"sslManualCert": "Сертифікат (PEM)",
"sslManualCertPlaceholder": "-----ПОЧАТИ СЕРТИФІКАТ-----",
"sslManualKey": "Закритий ключ (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "Không thể lưu cài đặt thương hiệu.",
"sslDescription": "Tự động cấp và gia hạn chứng chỉ SSL tin cậy từ Let's Encrypt. Yêu cầu tên miền công khai và quyền truy cập cổng 80 hoặc DNS.",
"sslDocsLink": "Xem tài liệu SSL",
"sslDomain": "Tên miền",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "Tên miền công khai để cấp chứng chỉ.",
"sslEmail": "Email",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "Email liên hệ để nhận thông báo và tài khoản từ Let's Encrypt.",
"sslChallengeType": "Loại thử thách",
"sslChallengeTypeDesc": "Cách chứng minh quyền sở hữu tên miền với Let's Encrypt.",
"sslChallengeHttp": "HTTP (webroot) - yêu cầu cổng 80 có thể truy cập từ internet",
"sslChallengeDns": "DNS (Cloudflare) - yêu cầu Token API Cloudflare",
"sslCloudflareToken": "Token API Cloudflare",
"sslCloudflareTokenPlaceholder": "Nhập token...",
"sslCloudflareTokenDesc": "Token có phạm vi với quyền Zone:DNS:Edit cho tên miền của bạn.",
"sslCertStatus": "Trạng thái chứng chỉ",
"sslCertStatusNone": "Không có chứng chỉ",
"sslCertStatusValid": "Hợp lệ",
"sslCertStatusExpiring": "Sắp hết hạn",
"sslCertStatusExpired": "Đã hết hạn",
"sslCertExpiresAt": "Hết hạn {{date}}",
"sslLastIssued": "Cấp lần cuối {{date}}",
"sslRequestCert": "Cấp / Gia hạn chứng chỉ",
"sslRequestCertLoading": "Đang yêu cầu chứng chỉ...",
"sslRequestCertSuccess": "Chứng chỉ đã được cấp và cài đặt thành công",
"sslRequestCertFailed": "Yêu cầu chứng chỉ thất bại",
"sslSave": "Lưu cài đặt",
"sslSaved": "Đã lưu cài đặt SSL",
"sslSaveFailed": "Không thể lưu cài đặt SSL",
"sslRequiresDomain": "Cần có tên miền và email",
"sslInfoNote": "Sau khi cấp hoặc tải lên chứng chỉ, Termix sẽ tự động bật HTTPS trên SSL_PORT (mặc định là 8443) bên cạnh cổng HTTP hiện có. Hãy đảm bảo rằng cổng đó đã được công bố/ánh xạ tới vùng chứa này để có thể truy cập được.",
"sslManualOption": "Hướng dẫn thủ công (tải lên chứng chỉ)",
"sslManualCert": "Chứng chỉ (PEM)",
"sslManualCertPlaceholder": "-----BẮT ĐẦU GIẤY CHỨNG NHẬN-----",
"sslManualKey": "Khóa riêng tư (PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "品牌设置保存失败",
"sslDescription": "自动从 Let's Encrypt 签发并续期受信任的 SSL 证书。需要公共域名以及端口 80 或 DNS 访问权限。",
"sslDocsLink": "查看 SSL 文档",
"sslDomain": "域名",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "证书的公共域名。",
"sslEmail": "邮箱",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "用于 Let's Encrypt 通知和账户的联系邮箱。",
"sslChallengeType": "验证类型",
"sslChallengeTypeDesc": "如何向 Let's Encrypt 证明域名所有权。",
"sslChallengeHttp": "HTTP (webroot) - 需要从互联网可访问端口 80",
"sslChallengeDns": "DNS (Cloudflare) - 需要 Cloudflare API 令牌",
"sslCloudflareToken": "Cloudflare API 令牌",
"sslCloudflareTokenPlaceholder": "输入令牌...",
"sslCloudflareTokenDesc": "具有您域名的 Zone:DNS:Edit 权限的限定作用域令牌。",
"sslCertStatus": "证书状态",
"sslCertStatusNone": "无证书",
"sslCertStatusValid": "有效",
"sslCertStatusExpiring": "即将过期",
"sslCertStatusExpired": "已过期",
"sslCertExpiresAt": "过期于 {{date}}",
"sslLastIssued": "上次签发于 {{date}}",
"sslRequestCert": "签发 / 续期证书",
"sslRequestCertLoading": "正在请求证书...",
"sslRequestCertSuccess": "证书签发并安装成功",
"sslRequestCertFailed": "证书请求失败",
"sslSave": "保存设置",
"sslSaved": "SSL 设置已保存",
"sslSaveFailed": "保存 SSL 设置失败",
"sslRequiresDomain": "域名和邮箱为必填项",
"sslInfoNote": "签发或上传证书后,Termix 会在现有 HTTP 端口的基础上,自动在 SSL_PORT(默认 8443)上启用 HTTPS。请确保已将该端口发布/映射到此容器,以便其可访问。",
"sslManualOption": "手动(上传证书)",
"sslManualCert": "证书(PEM)",
"sslManualCertPlaceholder": "-----BEGIN CERTIFICATE-----",
"sslManualKey": "私钥(PEM)",
-23
View File
@@ -1692,36 +1692,13 @@
"brandingSaveFailed": "品牌設定保存失敗",
"sslDescription": "自動從 Let's Encrypt 簽發並更新受信任的 SSL 憑證。需要公開網域及通訊埠 80 或 DNS 存取權。",
"sslDocsLink": "檢視 SSL 文件",
"sslDomain": "網域",
"sslDomainPlaceholder": "termix.example.com",
"sslDomainDesc": "憑證使用的公開網域名稱。",
"sslEmail": "電子郵件",
"sslEmailPlaceholder": "admin@example.com",
"sslEmailDesc": "用於 Let's Encrypt 通知及帳戶的聯絡電子郵件。",
"sslChallengeType": "驗證類型",
"sslChallengeTypeDesc": "如何向 Let's Encrypt 證明網域所有權。",
"sslChallengeHttp": "HTTP (webroot) - 需要可從網際網路存取的連接埠 80",
"sslChallengeDns": "DNS (Cloudflare) - 需要 Cloudflare API 權杖",
"sslCloudflareToken": "Cloudflare API 權杖",
"sslCloudflareTokenPlaceholder": "輸入權杖...",
"sslCloudflareTokenDesc": "具有您網域 Zone:DNS:Edit 權限的限定範圍權杖。",
"sslCertStatus": "憑證狀態",
"sslCertStatusNone": "無憑證",
"sslCertStatusValid": "有效",
"sslCertStatusExpiring": "即將到期",
"sslCertStatusExpired": "已過期",
"sslCertExpiresAt": "到期日 {{date}}",
"sslLastIssued": "上次簽發日期 {{date}}",
"sslRequestCert": "簽發 / 續期憑證",
"sslRequestCertLoading": "請求憑證中...",
"sslRequestCertSuccess": "憑證已成功簽發並安裝",
"sslRequestCertFailed": "憑證請求失敗",
"sslSave": "儲存設定",
"sslSaved": "SSL 設定已儲存",
"sslSaveFailed": "儲存 SSL 設定失敗",
"sslRequiresDomain": "網域和電子郵件為必填",
"sslInfoNote": "頒發或上傳憑證後,Termix 會在現有 HTTP 連接埠的基礎上,自動在 SSL_PORT(預設 8443)上啟用 HTTPS。請確保已將該連接埠發布/映射到此容器,以便其可存取。",
"sslManualOption": "手動(上傳證書)",
"sslManualCert": "證書(PEM)",
"sslManualCertPlaceholder": "-----開始憑證-----",
"sslManualKey": "私鑰(PEM)",
+10 -79
View File
@@ -49,12 +49,10 @@ import {
type BrandingSettings,
} from "@/api/settings-api";
import {
getAcmeSslSettings,
updateAcmeSslSettings,
requestAcmeCertificate,
uploadManualSslCertificate,
type AcmeSettings,
} from "@/api/acme-ssl-api";
getTlsStatus,
uploadTlsCertificate,
type TlsStatus,
} from "@/api/tls-api";
import {
type ApiKey,
type CreatedApiKey,
@@ -193,21 +191,7 @@ export function AdminSettingsPanel({
const [exportLoading, setExportLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
// ACME SSL state
const defaultAcmeSettings: AcmeSettings = {
enabled: false,
domain: "",
email: "",
challengeType: "http-webroot",
cloudflareToken: "",
lastIssuedAt: null,
certStatus: "none",
certExpiresAt: null,
};
const [acmeSettings, setAcmeSettings] =
useState<AcmeSettings>(defaultAcmeSettings);
const [cloudflareTokenDraft, setCloudflareTokenDraft] = useState("");
const [acmeRequesting, setAcmeRequesting] = useState(false);
const [tlsStatus, setTlsStatus] = useState<TlsStatus | null>(null);
const [manualCertDraft, setManualCertDraft] = useState("");
const [manualKeyDraft, setManualKeyDraft] = useState("");
const [manualUploading, setManualUploading] = useState(false);
@@ -369,8 +353,8 @@ export function AdminSettingsPanel({
.then((d) => setHostDefaults(d))
.catch(() => {});
getAcmeSslSettings()
.then((s) => setAcmeSettings(s))
getTlsStatus()
.then((s) => setTlsStatus(s))
.catch(() => {});
}
@@ -556,53 +540,6 @@ export function AdminSettingsPanel({
}
}
async function handleSaveAcmeSettings() {
try {
const payload: Parameters<typeof updateAcmeSslSettings>[0] = {
enabled: acmeSettings.enabled,
domain: acmeSettings.domain,
email: acmeSettings.email,
challengeType: acmeSettings.challengeType,
...(cloudflareTokenDraft && { cloudflareToken: cloudflareTokenDraft }),
};
const updated = await updateAcmeSslSettings(payload);
setAcmeSettings(updated);
setCloudflareTokenDraft("");
toast.success(t("admin.sslSaved"));
} catch {
toast.error(t("admin.sslSaveFailed"));
}
}
async function handleRequestAcmeCertificate() {
if (!acmeSettings.domain || !acmeSettings.email) {
toast.error(t("admin.sslRequiresDomain"));
return;
}
setAcmeRequesting(true);
try {
if (cloudflareTokenDraft) {
await updateAcmeSslSettings({
domain: acmeSettings.domain,
email: acmeSettings.email,
challengeType: acmeSettings.challengeType,
cloudflareToken: cloudflareTokenDraft,
});
setCloudflareTokenDraft("");
}
const result = await requestAcmeCertificate();
setAcmeSettings(result);
toast.success(t("admin.sslRequestCertSuccess"));
if (result.reloadMessage) {
toast.info(result.reloadMessage);
}
} catch (e) {
toast.error(apiErrorMessage(e, t("admin.sslRequestCertFailed")));
} finally {
setAcmeRequesting(false);
}
}
async function handleManualSslUpload() {
if (!manualCertDraft.trim() || !manualKeyDraft.trim()) {
toast.error(t("admin.sslManualRequiresFields"));
@@ -610,11 +547,11 @@ export function AdminSettingsPanel({
}
setManualUploading(true);
try {
const result = await uploadManualSslCertificate({
const result = await uploadTlsCertificate({
certificate: manualCertDraft,
privateKey: manualKeyDraft,
});
setAcmeSettings(result);
setTlsStatus(result);
setManualCertDraft("");
setManualKeyDraft("");
toast.success(t("admin.sslManualUploadSuccess"));
@@ -997,13 +934,7 @@ export function AdminSettingsPanel({
<AdminSSLSection
open={openSections.has("ssl")}
onToggle={() => toggle("ssl")}
settings={acmeSettings}
setSettings={setAcmeSettings}
cloudflareTokenDraft={cloudflareTokenDraft}
setCloudflareTokenDraft={setCloudflareTokenDraft}
requesting={acmeRequesting}
handleSave={handleSaveAcmeSettings}
handleRequest={handleRequestAcmeCertificate}
status={tlsStatus}
manualCertDraft={manualCertDraft}
setManualCertDraft={setManualCertDraft}
manualKeyDraft={manualKeyDraft}
+93 -177
View File
@@ -4,11 +4,10 @@ import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { SettingRow } from "@/components/section-card";
import { Select2 } from "@/components/select2";
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import type { HostDefaults } from "@/api/settings-api";
import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api";
import type { TlsStatus } from "@/api/tls-api";
type GeneralSettingsSectionProps = {
open: boolean;
@@ -541,7 +540,18 @@ export function AdminHostDefaultsSection({
);
}
const CERT_STATUS_STYLES: Record<AcmeSettings["certStatus"], string> = {
function certState(
status: TlsStatus | null,
): "none" | "valid" | "expiring" | "expired" {
const cert = status?.certificate;
if (!cert) return "none";
const left = new Date(cert.notAfter).getTime() - Date.now();
if (left <= 0) return "expired";
if (left < 30 * 86_400_000) return "expiring";
return "valid";
}
const CERT_STATUS_STYLES: Record<ReturnType<typeof certState>, string> = {
none: "text-muted-foreground",
valid: "text-green-500",
expiring: "text-yellow-500",
@@ -551,13 +561,7 @@ const CERT_STATUS_STYLES: Record<AcmeSettings["certStatus"], string> = {
type AdminSSLSectionProps = {
open: boolean;
onToggle: () => void;
settings: AcmeSettings;
setSettings: Dispatch<SetStateAction<AcmeSettings>>;
cloudflareTokenDraft: string;
setCloudflareTokenDraft: Dispatch<SetStateAction<string>>;
requesting: boolean;
handleSave: () => void;
handleRequest: () => void;
status: TlsStatus | null;
manualCertDraft: string;
setManualCertDraft: Dispatch<SetStateAction<string>>;
manualKeyDraft: string;
@@ -569,13 +573,7 @@ type AdminSSLSectionProps = {
export function AdminSSLSection({
open,
onToggle,
settings,
setSettings,
cloudflareTokenDraft,
setCloudflareTokenDraft,
requesting,
handleSave,
handleRequest,
status,
manualCertDraft,
setManualCertDraft,
manualKeyDraft,
@@ -584,8 +582,11 @@ export function AdminSSLSection({
handleManualUpload,
}: AdminSSLSectionProps) {
const { t } = useTranslation();
const cert = status?.certificate ?? null;
const state = certState(status);
const expiry = cert ? new Date(cert.notAfter).toLocaleDateString() : "";
const certStatusLabel: Record<AcmeSettings["certStatus"], string> = {
const certStatusLabel: Record<ReturnType<typeof certState>, string> = {
none: t("admin.sslCertStatusNone"),
valid: t("admin.sslCertStatusValid"),
expiring: t("admin.sslCertStatusExpiring"),
@@ -618,182 +619,97 @@ export function AdminSSLSection({
{t("admin.sslCertStatus")}
</span>
<span
className={`text-xs font-medium ${CERT_STATUS_STYLES[settings.certStatus]}`}
className={`text-xs font-medium ${CERT_STATUS_STYLES[state]}`}
>
{certStatusLabel[settings.certStatus]}
{certStatusLabel[state]}
</span>
</div>
{settings.certExpiresAt && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslCertExpiresAt", {
date: new Date(settings.certExpiresAt).toLocaleDateString(),
})}
</span>
)}
{settings.lastIssuedAt && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslLastIssued", {
date: new Date(settings.lastIssuedAt).toLocaleString(),
})}
</span>
{cert && (
<>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslNames", { names: cert.names.join(", ") || "-" })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslIssuer", { issuer: cert.issuer })}
</span>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslCertExpiresAt", { date: expiry })}
</span>
{cert.selfSigned && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslSelfSigned")}
</span>
)}
{status?.renewal && (
<span className="text-[10px] text-muted-foreground">
{t("admin.sslRenewedBy", {
plugin: status.renewal.pluginName,
})}
</span>
)}
</>
)}
</div>
{cert && !cert.selfSigned && !status?.renewal && (
<div className="p-2 border border-yellow-500/40 bg-yellow-500/10 text-[10px] text-yellow-600 dark:text-yellow-400">
{t("admin.sslNotRenewed", { date: expiry })}
</div>
)}
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest border-t border-border pt-2">
{t("admin.sslManualTitle")}
</span>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualCert")}
</label>
<textarea
rows={5}
value={manualCertDraft}
onChange={(e) => setManualCertDraft(e.target.value)}
placeholder={t("admin.sslManualCertPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslChallengeType")}
{t("admin.sslManualKey")}
</label>
<Select2
value={settings.challengeType}
onChange={(event) =>
setSettings((p) => ({
...p,
challengeType: event.target.value as AcmeChallengeType,
}))
}
className="w-full text-xs h-8"
>
<option value="http-webroot">HTTP (webroot)</option>
<option value="dns-cloudflare">DNS (Cloudflare)</option>
<option value="manual">{t("admin.sslManualOption")}</option>
</Select2>
<textarea
rows={5}
value={manualKeyDraft}
onChange={(e) => setManualKeyDraft(e.target.value)}
placeholder={t("admin.sslManualKeyPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslChallengeTypeDesc")}
{t("admin.sslManualDesc")}
</span>
</div>
{settings.challengeType !== "manual" && (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslDomain")}
</label>
<Input
value={settings.domain}
onChange={(e) =>
setSettings((p) => ({ ...p, domain: e.target.value }))
}
placeholder={t("admin.sslDomainPlaceholder")}
className="text-xs"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslEmail")}
</label>
<Input
value={settings.email}
onChange={(e) =>
setSettings((p) => ({ ...p, email: e.target.value }))
}
placeholder={t("admin.sslEmailPlaceholder")}
className="text-xs"
/>
</div>
</>
)}
{settings.challengeType === "dns-cloudflare" && (
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslCloudflareToken")}
</label>
<PasswordInput
value={cloudflareTokenDraft}
onChange={(e) => setCloudflareTokenDraft(e.target.value)}
placeholder={
settings.cloudflareToken ||
t("admin.sslCloudflareTokenPlaceholder")
}
className="text-xs h-8 pr-8"
/>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslCloudflareTokenDesc")}
</span>
</div>
)}
{settings.challengeType === "manual" && (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualCert")}
</label>
<textarea
rows={5}
value={manualCertDraft}
onChange={(e) => setManualCertDraft(e.target.value)}
placeholder={t("admin.sslManualCertPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
{t("admin.sslManualKey")}
</label>
<textarea
rows={5}
value={manualKeyDraft}
onChange={(e) => setManualKeyDraft(e.target.value)}
placeholder={t("admin.sslManualKeyPlaceholder")}
spellCheck={false}
className="w-full px-2 py-1.5 text-[10px] font-mono bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring"
/>
<span className="text-[10px] text-muted-foreground">
{t("admin.sslManualDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleManualUpload}
disabled={manualUploading}
>
<RefreshCw
className={`size-3 ${manualUploading ? "animate-spin" : ""}`}
/>
{manualUploading
? t("admin.sslManualUploadLoading")
: t("admin.sslManualUpload")}
</Button>
</>
)}
<Button
variant="outline"
size="sm"
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleManualUpload}
disabled={manualUploading}
>
<RefreshCw
className={`size-3 ${manualUploading ? "animate-spin" : ""}`}
/>
{manualUploading
? t("admin.sslManualUploadLoading")
: t("admin.sslManualUpload")}
</Button>
<span className="text-[10px] text-muted-foreground border-t border-border pt-2">
{t("admin.sslInfoNote")}
</span>
{settings.challengeType !== "manual" && (
<div className="flex flex-col gap-1.5">
<Button
variant="outline"
size="sm"
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSave}
>
{t("admin.sslSave")}
</Button>
<Button
variant="outline"
size="sm"
className="w-full text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleRequest}
disabled={requesting}
>
<RefreshCw
className={`size-3 ${requesting ? "animate-spin" : ""}`}
/>
{requesting
? t("admin.sslRequestCertLoading")
: t("admin.sslRequestCert")}
</Button>
</div>
)}
</div>
</AccordionSection>
);