mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
import {
|
|
execCommand,
|
|
execElevated,
|
|
shellSingleQuote,
|
|
detectPlatform,
|
|
} from "@termix/plugin-sdk/host-commands";
|
|
import {
|
|
isValidDomain,
|
|
isValidDnsProvider,
|
|
isAllowedPath,
|
|
} from "./validation.js";
|
|
import type { Router } from "express";
|
|
import { managerHandler, ManagerInputError } from "./route-helpers.js";
|
|
import type { ManagerRoutesDeps } from "./types.js";
|
|
|
|
export type AcmeClient = "certbot" | "acme.sh";
|
|
export type ChallengeType = "http-standalone" | "http-webroot" | "dns";
|
|
|
|
export interface CertInfo {
|
|
client: AcmeClient | "other";
|
|
name: string;
|
|
domains: string[];
|
|
expiry: string | null;
|
|
path?: string;
|
|
}
|
|
|
|
const CERTBOT_LIST_CMD = "certbot certificates 2>/dev/null";
|
|
const ACMESH_BIN = '"$(command -v acme.sh || echo "$HOME/.acme.sh/acme.sh")"';
|
|
const ACMESH_LIST_CMD = `${ACMESH_BIN} --list 2>/dev/null`;
|
|
|
|
/** Parse `certbot certificates` output. */
|
|
export function parseCertbotCertificates(output: string): CertInfo[] {
|
|
const certs: CertInfo[] = [];
|
|
let current: CertInfo | null = null;
|
|
for (const raw of output.split("\n")) {
|
|
const line = raw.trim();
|
|
const nameM = line.match(/^Certificate Name:\s+(.+)$/);
|
|
if (nameM) {
|
|
if (current) certs.push(current);
|
|
current = {
|
|
client: "certbot",
|
|
name: nameM[1],
|
|
domains: [],
|
|
expiry: null,
|
|
};
|
|
continue;
|
|
}
|
|
if (!current) continue;
|
|
const domM = line.match(/^Domains:\s+(.+)$/);
|
|
if (domM) current.domains = domM[1].split(/\s+/).filter(Boolean);
|
|
const expM = line.match(/^Expiry Date:\s+(\S+\s+\S+)/);
|
|
if (expM) current.expiry = expM[1];
|
|
const pathM = line.match(/^Certificate Path:\s+(.+)$/);
|
|
if (pathM) current.path = pathM[1];
|
|
}
|
|
if (current) certs.push(current);
|
|
return certs;
|
|
}
|
|
|
|
/** Parse `acme.sh --list` (tab/space separated columns with a header). */
|
|
export function parseAcmeShList(output: string): CertInfo[] {
|
|
const certs: CertInfo[] = [];
|
|
const lines = output.split("\n").filter((l) => l.trim());
|
|
if (lines.length < 2) return certs;
|
|
for (const line of lines.slice(1)) {
|
|
const cols = line
|
|
.split(/\s{2,}|\t/)
|
|
.map((c) => c.trim())
|
|
.filter(Boolean);
|
|
if (cols.length < 1) continue;
|
|
certs.push({
|
|
client: "acme.sh",
|
|
name: cols[0],
|
|
domains: [cols[0]],
|
|
expiry: cols[cols.length - 1] || null,
|
|
});
|
|
}
|
|
return certs;
|
|
}
|
|
|
|
/**
|
|
* A certbot cert name or acme.sh primary domain. Allows letters, digits, dots,
|
|
* hyphens, underscores and the wildcard `*` (acme.sh), but no shell metachars.
|
|
*/
|
|
const CERT_NAME_RE = /^[A-Za-z0-9._*-]+$/;
|
|
export function isValidCertName(name: unknown): name is string {
|
|
return typeof name === "string" && name.length > 0 && CERT_NAME_RE.test(name);
|
|
}
|
|
|
|
export interface IssueRequest {
|
|
client: AcmeClient;
|
|
domains: string[];
|
|
challenge: ChallengeType;
|
|
webroot?: string;
|
|
dnsProvider?: string;
|
|
}
|
|
|
|
/**
|
|
* Build the certificate issuance command for the chosen client/challenge.
|
|
* DNS-01 provider credentials are expected to already be present in the
|
|
* environment / provider config file; we never put secrets in argv.
|
|
*/
|
|
export function buildIssueCommand(req: IssueRequest): string {
|
|
const domains = req.domains;
|
|
if (req.client === "certbot") {
|
|
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
|
|
if (req.challenge === "dns") {
|
|
return `certbot certonly --non-interactive --agree-tos --dns-${req.dnsProvider} ${dFlags}`;
|
|
}
|
|
if (req.challenge === "http-webroot") {
|
|
return `certbot certonly --non-interactive --agree-tos --webroot -w ${shellSingleQuote(
|
|
req.webroot as string,
|
|
)} ${dFlags}`;
|
|
}
|
|
return `certbot certonly --non-interactive --agree-tos --standalone ${dFlags}`;
|
|
}
|
|
// acme.sh
|
|
const dFlags = domains.map((d) => `-d ${shellSingleQuote(d)}`).join(" ");
|
|
if (req.challenge === "dns") {
|
|
return `${ACMESH_BIN} --issue --dns dns_${req.dnsProvider} ${dFlags}`;
|
|
}
|
|
if (req.challenge === "http-webroot") {
|
|
return `${ACMESH_BIN} --issue -w ${shellSingleQuote(
|
|
req.webroot as string,
|
|
)} ${dFlags}`;
|
|
}
|
|
return `${ACMESH_BIN} --issue --standalone ${dFlags}`;
|
|
}
|
|
|
|
export function buildRenewCommand(client: AcmeClient, dryRun: boolean): string {
|
|
if (client === "certbot") {
|
|
return `certbot renew${dryRun ? " --dry-run" : ""}`;
|
|
}
|
|
return `${ACMESH_BIN} --renew-all${dryRun ? " --staging" : ""}`;
|
|
}
|
|
|
|
/**
|
|
* Revoke (and remove) a certificate. certbot revokes by its certificate name and
|
|
* deletes the lineage afterwards; acme.sh revokes by primary domain then removes
|
|
* it from management. `name` is the certbot cert name or the acme.sh domain.
|
|
*/
|
|
export function buildRevokeCommand(client: AcmeClient, name: string): string {
|
|
if (client === "certbot") {
|
|
return `certbot revoke --non-interactive --cert-name ${shellSingleQuote(
|
|
name,
|
|
)} --delete-after-revoke`;
|
|
}
|
|
const d = shellSingleQuote(name);
|
|
return `${ACMESH_BIN} --revoke -d ${d} && ${ACMESH_BIN} --remove -d ${d}`;
|
|
}
|
|
|
|
export function registerSslRoutes(app: Router, deps: ManagerRoutesDeps): void {
|
|
const { validateHostId } = deps;
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/host-metrics/managers/ssl/{id}:
|
|
* get:
|
|
* summary: List certificates from certbot and acme.sh
|
|
* tags: [Host Metrics]
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema: { type: integer }
|
|
* responses:
|
|
* 200: { description: The installed ACME clients and certificates. }
|
|
* 400: { description: Invalid input. }
|
|
* 403: { description: No access to the host, or elevation denied. }
|
|
* 500: { description: The command failed on the host. }
|
|
*/
|
|
app.get(
|
|
"/host-metrics/managers/ssl/:id",
|
|
validateHostId,
|
|
managerHandler(deps, "connect", "ssl_list", async (client, host) => {
|
|
const platform = await detectPlatform(client);
|
|
const certs: CertInfo[] = [];
|
|
if (platform.hasCertbot) {
|
|
const r = await execElevated(
|
|
client,
|
|
CERTBOT_LIST_CMD,
|
|
host.sudoPassword,
|
|
).catch(() => null);
|
|
if (r) certs.push(...parseCertbotCertificates(r.stdout));
|
|
}
|
|
if (platform.hasAcmeSh) {
|
|
const { stdout } = await execCommand(
|
|
client,
|
|
ACMESH_LIST_CMD,
|
|
15000,
|
|
).catch(() => ({ stdout: "" }) as { stdout: string });
|
|
certs.push(...parseAcmeShList(stdout));
|
|
}
|
|
return {
|
|
clients: {
|
|
certbot: platform.hasCertbot,
|
|
acmeSh: platform.hasAcmeSh,
|
|
},
|
|
certs,
|
|
};
|
|
}),
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/host-metrics/managers/ssl/{id}/issue:
|
|
* post:
|
|
* summary: Issue a certificate with certbot or acme.sh
|
|
* tags: [Host Metrics]
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema: { type: integer }
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [client, domains, challenge]
|
|
* properties:
|
|
* client: { type: string, enum: [certbot, acme.sh] }
|
|
* domains: { type: array, items: { type: string } }
|
|
* challenge: { type: string, enum: [http-standalone, http-webroot, dns] }
|
|
* webroot: { type: string }
|
|
* dnsProvider: { type: string }
|
|
* responses:
|
|
* 200: { description: The issue result. }
|
|
* 400: { description: Invalid input. }
|
|
* 403: { description: No access to the host, or elevation denied. }
|
|
* 500: { description: The command failed on the host. }
|
|
*/
|
|
app.post(
|
|
"/host-metrics/managers/ssl/:id/issue",
|
|
validateHostId,
|
|
managerHandler(deps, "connect", "ssl_issue", async (client, host, req) => {
|
|
const body = req.body as Partial<IssueRequest>;
|
|
if (body.client !== "certbot" && body.client !== "acme.sh") {
|
|
throw new ManagerInputError("Invalid ACME client");
|
|
}
|
|
if (!Array.isArray(body.domains) || body.domains.length === 0) {
|
|
throw new ManagerInputError("At least one domain is required");
|
|
}
|
|
for (const d of body.domains) {
|
|
if (!isValidDomain(d))
|
|
throw new ManagerInputError(`Invalid domain: ${d}`);
|
|
}
|
|
const challenge = body.challenge;
|
|
if (
|
|
challenge !== "http-standalone" &&
|
|
challenge !== "http-webroot" &&
|
|
challenge !== "dns"
|
|
) {
|
|
throw new ManagerInputError("Invalid challenge type");
|
|
}
|
|
if (challenge === "dns" && !isValidDnsProvider(body.dnsProvider)) {
|
|
throw new ManagerInputError("Invalid DNS provider");
|
|
}
|
|
if (
|
|
challenge === "http-webroot" &&
|
|
!isAllowedPath(body.webroot, ["/var/www", "/srv", "/usr/share/nginx"])
|
|
) {
|
|
throw new ManagerInputError("Invalid or disallowed webroot path");
|
|
}
|
|
const cmd = buildIssueCommand(body as IssueRequest);
|
|
const result = await execElevated(client, cmd, host.sudoPassword, {
|
|
forceSudo: true,
|
|
timeoutMs: 300000,
|
|
});
|
|
return {
|
|
success: result.code === 0,
|
|
output: (result.stdout || result.stderr).slice(-8000),
|
|
};
|
|
}),
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /plugin-api/host-metrics/managers/ssl/{id}/renew:
|
|
* post:
|
|
* summary: Renew certificates
|
|
* tags: [Host Metrics]
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema: { type: integer }
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* required: [client]
|
|
* properties:
|
|
* client: { type: string, enum: [certbot, acme.sh] }
|
|
* dryRun: { type: boolean }
|
|
* responses:
|
|
* 200: { description: The renew result. }
|
|
* 400: { description: Invalid input. }
|
|
* 403: { description: No access to the host, or elevation denied. }
|
|
* 500: { description: The command failed on the host. }
|
|
*/
|
|
app.post(
|
|
"/host-metrics/managers/ssl/:id/renew",
|
|
validateHostId,
|
|
managerHandler(deps, "connect", "ssl_renew", async (client, host, req) => {
|
|
const { client: acmeClient, dryRun } = req.body as {
|
|
client?: AcmeClient;
|
|
dryRun?: boolean;
|
|
};
|
|
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
|
|
throw new ManagerInputError("Invalid ACME client");
|
|
}
|
|
const cmd = buildRenewCommand(acmeClient, !!dryRun);
|
|
const result = await execElevated(client, cmd, host.sudoPassword, {
|
|
forceSudo: true,
|
|
timeoutMs: 300000,
|
|
});
|
|
return {
|
|
success: result.code === 0,
|
|
output: (result.stdout || result.stderr).slice(-8000),
|
|
};
|
|
}),
|
|
);
|
|
|
|
/**
|
|
* @openapi
|
|
* /host-metrics/managers/ssl/{id}/revoke:
|
|
* post:
|
|
* summary: Revoke and remove an issued certificate (certbot or acme.sh)
|
|
* tags: [Host Metrics]
|
|
* parameters:
|
|
* - in: path
|
|
* name: id
|
|
* required: true
|
|
* schema: { type: integer }
|
|
* requestBody:
|
|
* required: true
|
|
* content:
|
|
* application/json:
|
|
* schema:
|
|
* type: object
|
|
* properties:
|
|
* client: { type: string, enum: [certbot, acme.sh] }
|
|
* name: { type: string, description: certbot cert name or acme.sh domain }
|
|
* responses:
|
|
* 200: { description: Revoke result. }
|
|
* 400: { description: Invalid client or certificate name. }
|
|
* 403: { description: Elevation required or denied. }
|
|
*/
|
|
app.post(
|
|
"/host-metrics/managers/ssl/:id/revoke",
|
|
validateHostId,
|
|
managerHandler(deps, "connect", "ssl_revoke", async (client, host, req) => {
|
|
const { client: acmeClient, name } = req.body as {
|
|
client?: AcmeClient;
|
|
name?: string;
|
|
};
|
|
if (acmeClient !== "certbot" && acmeClient !== "acme.sh") {
|
|
throw new ManagerInputError("Invalid ACME client");
|
|
}
|
|
if (!isValidCertName(name)) {
|
|
throw new ManagerInputError("Invalid certificate name");
|
|
}
|
|
const cmd = buildRevokeCommand(acmeClient, name);
|
|
const result = await execElevated(client, cmd, host.sudoPassword, {
|
|
forceSudo: true,
|
|
timeoutMs: 120000,
|
|
});
|
|
return {
|
|
success: result.code === 0,
|
|
output: (result.stdout || result.stderr).slice(-8000),
|
|
};
|
|
}),
|
|
);
|
|
}
|